feat: переезд на tortose

This commit is contained in:
Artem Tsyrulnikov
2025-12-11 09:17:31 +03:00
parent 2af23f84fe
commit 0f0de396e1
16 changed files with 437 additions and 611 deletions

View File

@@ -16,6 +16,7 @@ dependencies = [
"pyjwt>=2.10.1", "pyjwt>=2.10.1",
"python-multipart>=0.0.20", "python-multipart>=0.0.20",
"sqlalchemy[asyncio]>=2.0.38", "sqlalchemy[asyncio]>=2.0.38",
"tortoise-orm>=0.25.1",
"uvicorn>=0.38.0", "uvicorn>=0.38.0",
] ]

View File

@@ -1,19 +1,5 @@
import asyncio
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from contextvars import ContextVar
import pydantic import pydantic
from sqlalchemy import text from tortoise import Tortoise
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): class DatabaseConfig(pydantic.BaseModel):
@@ -25,129 +11,59 @@ class DatabaseConfig(pydantic.BaseModel):
class DatabaseBase: class DatabaseBase:
def __init__(self, config: DatabaseConfig, migrations_path: str = 'migration/versions') -> None: def __init__(self, config: DatabaseConfig) -> None:
self.config = config 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 def connect(self) -> None:
async with self._connect_lock: await Tortoise.init(
if self.engine is not None: db_url=str(self.config.URL),
logging.warning('Database already connected') modules={'models': ['src.domain']},
return use_tz=True,
timezone='UTC',
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.ping()
await self._check_migrations() await self._check_migrations()
logging.info('Database initialized') async def close(self) -> None:
await Tortoise.close_connections()
async def ping(self) -> None: async def ping(self) -> None:
if self.session_factory is None: from tortoise import connections
raise RuntimeError('Database not connected. Call connect() first.')
conn = connections.get('default')
try: try:
async with self.session_factory() as session: await conn.execute_query('SELECT 1')
await session.execute(text('SELECT 1'))
except Exception as e: except Exception as e:
import logging
logging.error(f'Database ping failed: {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 raise
async def _check_migrations(self) -> None: async def _check_migrations(self) -> None:
if self.session_factory is None: import logging
raise RuntimeError('Database not connected. Call connect() first.')
async with self.session_factory() as session: from tortoise import connections
result = await session.execute(
text("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'alembic_version')") conn = connections.get('default')
# Проверяем существование таблицы alembic_version
result = await conn.execute_query(
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'alembic_version')"
) )
if not result.scalar(): if not result[1][0]['exists']:
logging.error('Migrations not applied. Run: alembic upgrade head') logging.error('Migrations not applied. Run: alembic upgrade head')
return return
result = await session.execute(text('SELECT version_num FROM alembic_version')) # Получаем текущую версию
current_version = result.scalar() result = await conn.execute_query('SELECT version_num FROM alembic_version')
if not result[1]:
if not current_version:
logging.error('No migration version found in database. Run: alembic upgrade head') logging.error('No migration version found in database. Run: alembic upgrade head')
return return
current_version = result[1][0]['version_num']
# Сравниваем с head версией
try: try:
from alembic.config import Config from alembic.config import Config
from alembic.script import ScriptDirectory from alembic.script import ScriptDirectory

View File

@@ -2,55 +2,41 @@ import datetime
import typing import typing
import uuid import uuid
from sqlalchemy import delete, select from tortoise.transactions import in_transaction
from sqlalchemy.orm import selectinload
from shared.datebase_base import DatabaseBase from shared.datebase_base import DatabaseBase
from src import domain from src import domain
class Postgres(DatabaseBase): class Postgres(DatabaseBase):
def transaction(self) -> typing.AsyncContextManager[None]:
return in_transaction()
async def get_user(self, user_id: uuid.UUID | None = None, telegram_id: int | None = None) -> domain.User | None: async def get_user(self, user_id: uuid.UUID | None = None, telegram_id: int | None = None) -> domain.User | None:
if user_id: if user_id:
return await self.session.get(domain.User, user_id) return await domain.User.get_or_none(id=user_id)
elif telegram_id: elif telegram_id:
q = select(domain.User).where(domain.User.telegram_id == telegram_id) return await domain.User.get_or_none(telegram_id=telegram_id)
result = await self.session.execute(q)
return result.scalar_one_or_none()
raise ValueError('Either user_id or telegram_id must be provided') raise ValueError('Either user_id or telegram_id must be provided')
async def create_user(self, user: domain.User) -> domain.User: async def create_user(self, user: domain.User) -> domain.User:
self.session.add(user) await user.save()
await self.session.flush()
await self.session.refresh(user)
return user return user
async def create_login_token( async def create_login_token(
self, user_id: uuid.UUID, token: str, expires_at: datetime.datetime self, user_id: uuid.UUID, token: str, expires_at: datetime.datetime
) -> domain.LoginToken: ) -> domain.LoginToken:
login_token = domain.LoginToken(user_id=user_id, token=token, expires_at=expires_at) login_token = await domain.LoginToken.create(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 return login_token
async def get_login_token(self, token: str) -> domain.LoginToken | None: async def get_login_token(self, token: str) -> domain.LoginToken | None:
q = select(domain.LoginToken).where(domain.LoginToken.token == token) return await domain.LoginToken.get_or_none(token=token)
result = await self.session.execute(q)
return result.scalar_one_or_none()
async def mark_token_as_used(self, token: str) -> None: async def mark_token_as_used(self, token: str) -> None:
q = select(domain.LoginToken).where(domain.LoginToken.token == token) login_token = await domain.LoginToken.get_or_none(token=token)
result = await self.session.execute(q)
login_token = result.scalar_one_or_none()
if login_token: if login_token:
login_token.used_at = datetime.datetime.now(datetime.UTC) login_token.used_at = datetime.datetime.now(datetime.UTC)
await self.session.flush() await login_token.save()
async def get_target_channel( async def get_target_channel(
self, user_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None self, user_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None
@@ -58,22 +44,19 @@ class Postgres(DatabaseBase):
if not channel_id and not telegram_id: if not channel_id and not telegram_id:
raise ValueError('Either channel_id or telegram_id must be provided') raise ValueError('Either channel_id or telegram_id must be provided')
q = select(domain.TargetChannel).where(domain.TargetChannel.user_id == user_id) filters = {'user_id': user_id}
if channel_id: if channel_id:
q = q.where(domain.TargetChannel.id == channel_id) filters['id'] = channel_id
if telegram_id: if telegram_id:
q = q.where(domain.TargetChannel.telegram_id == telegram_id) filters['telegram_id'] = telegram_id
result = await self.session.execute(q) return await domain.TargetChannel.get_or_none(**filters)
return result.scalar_one_or_none()
async def upsert_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel: 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) existing = await self.get_target_channel(user_id=channel.user_id, telegram_id=channel.telegram_id)
if not existing: if not existing:
self.session.add(channel) await channel.save()
await self.session.flush()
await self.session.refresh(channel)
return channel return channel
existing.title = channel.title existing.title = channel.title
@@ -82,180 +65,114 @@ class Postgres(DatabaseBase):
existing.status = channel.status existing.status = channel.status
existing.deleted_at = None existing.deleted_at = None
existing.updated_at = datetime.datetime.now(datetime.UTC) existing.updated_at = datetime.datetime.now(datetime.UTC)
await existing.save()
await self.session.flush()
await self.session.refresh(existing)
return existing return existing
async def get_user_target_channels(self, user_id: uuid.UUID) -> list[domain.TargetChannel]: async def get_user_target_channels(self, user_id: uuid.UUID) -> list[domain.TargetChannel]:
q = select(domain.TargetChannel).where( return await domain.TargetChannel.filter(user_id=user_id, status=domain.TargetChannelStatus.ACTIVE).all()
domain.TargetChannel.status == domain.TargetChannelStatus.ACTIVE,
domain.TargetChannel.user_id == user_id,
)
result = await self.session.execute(q)
return list(result.scalars().all())
async def update_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel: async def update_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel:
await self.session.flush() await channel.save()
await self.session.refresh(channel)
return channel return channel
async def get_external_channel( async def get_external_channel(
self, user_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None self, user_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None
) -> domain.ExternalChannel | None: ) -> domain.ExternalChannel | None:
q = select(domain.ExternalChannel).where(domain.ExternalChannel.user_id == user_id) filters = {'user_id': user_id}
if channel_id: if channel_id:
q = q.where(domain.ExternalChannel.id == channel_id) filters['id'] = channel_id
if telegram_id: if telegram_id:
q = q.where(domain.ExternalChannel.telegram_id == telegram_id) filters['telegram_id'] = telegram_id
result = await self.session.execute(q) return await domain.ExternalChannel.get_or_none(**filters)
return result.scalar_one_or_none()
async def create_external_channel(self, channel: domain.ExternalChannel) -> domain.ExternalChannel: async def create_external_channel(self, channel: domain.ExternalChannel) -> domain.ExternalChannel:
self.session.add(channel) await channel.save()
await self.session.flush()
await self.session.refresh(channel)
return channel return channel
async def get_external_channels_for_target(self, target_channel_id: uuid.UUID) -> list[domain.ExternalChannel]: async def get_external_channels_for_target(self, target_channel_id: uuid.UUID) -> list[domain.ExternalChannel]:
q = ( target = await domain.TargetChannel.get_or_none(id=target_channel_id).prefetch_related('external_channels')
select(domain.ExternalChannel) if not target:
.join(domain.ExternalChannel.target_channels) return []
.where(domain.TargetChannel.id == target_channel_id) return await target.external_channels.all()
)
result = await self.session.execute(q)
return list(result.scalars().all())
async def get_user_external_channels(self, user_id: uuid.UUID) -> list[domain.ExternalChannel]: async def get_user_external_channels(self, user_id: uuid.UUID) -> list[domain.ExternalChannel]:
q = select(domain.ExternalChannel).where(domain.ExternalChannel.user_id == user_id) return await domain.ExternalChannel.filter(user_id=user_id).all()
result = await self.session.execute(q)
return list(result.scalars().all())
async def add_external_channel_to_targets( async def add_external_channel_to_targets(
self, external_channel_id: uuid.UUID, target_channel_ids: list[uuid.UUID] self, external_channel_id: uuid.UUID, target_channel_ids: list[uuid.UUID]
) -> None: ) -> None:
q = ( external_channel = await domain.ExternalChannel.get_or_none(id=external_channel_id)
select(domain.ExternalChannel)
.where(domain.ExternalChannel.id == external_channel_id)
.options(selectinload(domain.ExternalChannel.target_channels))
)
result = await self.session.execute(q)
external_channel = result.scalar_one_or_none()
if not external_channel: if not external_channel:
raise ValueError(f'ExternalChannel {external_channel_id} not found') raise ValueError(f'ExternalChannel {external_channel_id} not found')
for target_id in target_channel_ids: for target_id in target_channel_ids:
target_channel = await self.session.get(domain.TargetChannel, target_id) target_channel = await domain.TargetChannel.get_or_none(id=target_id)
if target_channel and target_channel not in external_channel.target_channels: if target_channel:
external_channel.target_channels.append(target_channel) await external_channel.external_channels.add(target_channel)
await self.session.flush()
async def remove_external_channel_from_target( async def remove_external_channel_from_target(
self, external_channel_id: uuid.UUID, target_channel_id: uuid.UUID self, external_channel_id: uuid.UUID, target_channel_id: uuid.UUID
) -> None: ) -> None:
q = ( external_channel = await domain.ExternalChannel.get_or_none(id=external_channel_id)
select(domain.ExternalChannel)
.where(domain.ExternalChannel.id == external_channel_id)
.options(selectinload(domain.ExternalChannel.target_channels))
)
result = await self.session.execute(q)
external_channel = result.scalar_one_or_none()
if not external_channel: if not external_channel:
raise ValueError(f'ExternalChannel {external_channel_id} not found') raise ValueError(f'ExternalChannel {external_channel_id} not found')
target_channel = await self.session.get(domain.TargetChannel, target_channel_id) target_channel = await domain.TargetChannel.get_or_none(id=target_channel_id)
if target_channel and target_channel in external_channel.target_channels: if target_channel:
external_channel.target_channels.remove(target_channel) await external_channel.target_channels.remove(target_channel)
await self.session.flush()
async def delete_external_channel(self, channel_id: uuid.UUID) -> None: async def delete_external_channel(self, channel_id: uuid.UUID) -> None:
q = delete(domain.ExternalChannel).where(domain.ExternalChannel.id == channel_id) await domain.ExternalChannel.filter(id=channel_id).delete()
await self.session.execute(q)
await self.session.flush()
async def update_external_channel(self, channel: domain.ExternalChannel) -> domain.ExternalChannel: async def update_external_channel(self, channel: domain.ExternalChannel) -> domain.ExternalChannel:
await self.session.flush() await channel.save()
await self.session.refresh(channel)
return channel return channel
async def get_creative(self, user_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None: async def get_creative(self, user_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None:
q = ( return await domain.Creative.get_or_none(id=creative_id, user_id=user_id).prefetch_related('target_channel')
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(q)
return result.scalar_one_or_none()
async def create_creative(self, creative: domain.Creative) -> domain.Creative: async def create_creative(self, creative: domain.Creative) -> domain.Creative:
self.session.add(creative) await creative.save()
await self.session.flush() await creative.fetch_related('target_channel')
await self.session.refresh(creative)
return creative return creative
async def update_creative(self, creative: domain.Creative) -> domain.Creative: async def update_creative(self, creative: domain.Creative) -> domain.Creative:
await self.session.flush() await creative.save()
await self.session.refresh(creative) await creative.fetch_related('target_channel')
return creative return creative
async def get_user_creatives( async def get_user_creatives(
self, user_id: uuid.UUID, target_channel_id: uuid.UUID | None = None, include_archived: bool = False self, user_id: uuid.UUID, target_channel_id: uuid.UUID | None = None, include_archived: bool = False
) -> list[domain.Creative]: ) -> list[domain.Creative]:
q = ( query = domain.Creative.filter(user_id=user_id)
select(domain.Creative)
.options(selectinload(domain.Creative.target_channel))
.where(domain.Creative.user_id == user_id)
)
if target_channel_id: if target_channel_id:
q = q.where(domain.Creative.target_channel_id == target_channel_id) query = query.filter(target_channel_id=target_channel_id)
if not include_archived: if not include_archived:
q = q.where(domain.Creative.status == domain.CreativeStatus.ACTIVE) query = query.filter(status=domain.CreativeStatus.ACTIVE)
q = q.order_by(domain.Creative.created_at.desc()) return await query.prefetch_related('target_channel').order_by('-created_at').all()
result = await self.session.execute(q)
return list(result.scalars().all())
async def delete_creative(self, creative_id: uuid.UUID) -> None: async def delete_creative(self, creative_id: uuid.UUID) -> None:
q = delete(domain.Creative).where(domain.Creative.id == creative_id) await domain.Creative.filter(id=creative_id).delete()
await self.session.execute(q)
await self.session.flush()
async def get_placement(self, user_id: uuid.UUID, placement_id: uuid.UUID) -> domain.Placement | None: async def get_placement(self, user_id: uuid.UUID, placement_id: uuid.UUID) -> domain.Placement | None:
q = ( return await domain.Placement.get_or_none(id=placement_id, user_id=user_id).prefetch_related(
select(domain.Placement) 'target_channel', 'external_channel', 'creative'
.options(
selectinload(domain.Placement.target_channel),
selectinload(domain.Placement.external_channel),
selectinload(domain.Placement.creative),
) )
.where(domain.Placement.id == placement_id, domain.Placement.user_id == user_id)
)
result = await self.session.execute(q)
return result.scalar_one_or_none()
async def create_placement(self, placement: domain.Placement) -> domain.Placement: async def create_placement(self, placement: domain.Placement) -> domain.Placement:
self.session.add(placement) await placement.save()
await self.session.flush() await placement.fetch_related('target_channel', 'external_channel', 'creative')
await self.session.refresh(
placement,
['target_channel', 'external_channel', 'creative'],
)
return placement return placement
async def update_placement(self, placement: domain.Placement) -> domain.Placement: async def update_placement(self, placement: domain.Placement) -> domain.Placement:
await self.session.flush() await placement.save()
await self.session.refresh( await placement.fetch_related('target_channel', 'external_channel', 'creative')
placement,
['target_channel', 'external_channel', 'creative'],
)
return placement return placement
async def get_user_placements( async def get_user_placements(
@@ -266,56 +183,39 @@ class Postgres(DatabaseBase):
creative_id: uuid.UUID | None = None, creative_id: uuid.UUID | None = None,
include_archived: bool = False, include_archived: bool = False,
) -> list[domain.Placement]: ) -> list[domain.Placement]:
q = ( query = domain.Placement.filter(user_id=user_id)
select(domain.Placement)
.options(
selectinload(domain.Placement.target_channel),
selectinload(domain.Placement.external_channel),
selectinload(domain.Placement.creative),
)
.where(domain.Placement.user_id == user_id)
)
if target_channel_id: if target_channel_id:
q = q.where(domain.Placement.target_channel_id == target_channel_id) query = query.filter(target_channel_id=target_channel_id)
if external_channel_id: if external_channel_id:
q = q.where(domain.Placement.external_channel_id == external_channel_id) query = query.filter(external_channel_id=external_channel_id)
if creative_id: if creative_id:
q = q.where(domain.Placement.creative_id == creative_id) query = query.filter(creative_id=creative_id)
if not include_archived: if not include_archived:
q = q.where(domain.Placement.status == domain.PlacementStatus.ACTIVE) query = query.filter(status=domain.PlacementStatus.ACTIVE)
q = q.order_by(domain.Placement.placement_date.desc()) return (
await query.prefetch_related('target_channel', 'external_channel', 'creative')
result = await self.session.execute(q) .order_by('-placement_date')
return list(result.scalars().all()) .all()
)
async def delete_placement(self, placement_id: uuid.UUID) -> None: async def delete_placement(self, placement_id: uuid.UUID) -> None:
q = delete(domain.Placement).where(domain.Placement.id == placement_id) await domain.Placement.filter(id=placement_id).delete()
await self.session.execute(q)
await self.session.flush()
async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None: async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None:
q = ( return await domain.Placement.get_or_none(invite_link=invite_link).prefetch_related(
select(domain.Placement) 'target_channel', 'external_channel', 'creative'
.options(
selectinload(domain.Placement.target_channel),
selectinload(domain.Placement.external_channel),
selectinload(domain.Placement.creative),
) )
.where(domain.Placement.invite_link == invite_link)
)
result = await self.session.execute(q)
return result.scalar_one_or_none()
async def get_placement_by_id(self, placement_id: uuid.UUID) -> domain.Placement | None: async def get_placement_by_id(self, placement_id: uuid.UUID) -> domain.Placement | None:
return await self.session.get(domain.Placement, placement_id) return await domain.Placement.get_or_none(id=placement_id)
async def update_placement_from_post_found( async def update_placement_from_post_found(
self, placement_id: uuid.UUID, ad_post_url: str, message_id: int, views_count: int self, placement_id: uuid.UUID, ad_post_url: str, message_id: int, views_count: int
) -> domain.Placement: ) -> domain.Placement:
placement = await self.session.get(domain.Placement, placement_id) placement = await domain.Placement.get_or_none(id=placement_id)
if not placement: if not placement:
raise ValueError(f'Placement {placement_id} not found') raise ValueError(f'Placement {placement_id} not found')
@@ -326,42 +226,34 @@ class Postgres(DatabaseBase):
placement.views_availability = domain.PostViewsAvailability.AVAILABLE placement.views_availability = domain.PostViewsAvailability.AVAILABLE
placement.last_views_fetch_at = datetime.datetime.now(datetime.UTC) placement.last_views_fetch_at = datetime.datetime.now(datetime.UTC)
await self.session.flush() await placement.save()
await self.session.refresh(placement)
return placement return placement
async def update_placement_from_post_deleted(self, placement_id: uuid.UUID) -> domain.Placement: async def update_placement_from_post_deleted(self, placement_id: uuid.UUID) -> domain.Placement:
placement = await self.session.get(domain.Placement, placement_id) placement = await domain.Placement.get_or_none(id=placement_id)
if not placement: if not placement:
raise ValueError(f'Placement {placement_id} not found') raise ValueError(f'Placement {placement_id} not found')
placement.status = domain.PlacementStatus.DELETED placement.status = domain.PlacementStatus.DELETED
await placement.save()
await self.session.flush()
await self.session.refresh(placement)
return placement return placement
async def update_placement_views(self, placement_id: uuid.UUID, views_count: int) -> domain.Placement: async def update_placement_views(self, placement_id: uuid.UUID, views_count: int) -> domain.Placement:
placement = await self.session.get(domain.Placement, placement_id) placement = await domain.Placement.get_or_none(id=placement_id)
if not placement: if not placement:
raise ValueError(f'Placement {placement_id} not found') raise ValueError(f'Placement {placement_id} not found')
placement.views_count = views_count placement.views_count = views_count
placement.last_views_fetch_at = datetime.datetime.now(datetime.UTC) placement.last_views_fetch_at = datetime.datetime.now(datetime.UTC)
await self.session.flush() await placement.save()
await self.session.refresh(placement)
return placement return placement
async def get_subscriber(self, telegram_id: int) -> domain.Subscriber | None: async def get_subscriber(self, telegram_id: int) -> domain.Subscriber | None:
q = select(domain.Subscriber).where(domain.Subscriber.telegram_id == telegram_id) return await domain.Subscriber.get_or_none(telegram_id=telegram_id)
result = await self.session.execute(q)
return result.scalar_one_or_none()
async def create_subscriber(self, subscriber: domain.Subscriber) -> domain.Subscriber: async def create_subscriber(self, subscriber: domain.Subscriber) -> domain.Subscriber:
self.session.add(subscriber) await subscriber.save()
await self.session.flush()
await self.session.refresh(subscriber)
return subscriber return subscriber
async def upsert_subscriber(self, subscriber: domain.Subscriber) -> domain.Subscriber: async def upsert_subscriber(self, subscriber: domain.Subscriber) -> domain.Subscriber:
@@ -371,73 +263,56 @@ class Postgres(DatabaseBase):
existing.username = subscriber.username existing.username = subscriber.username
existing.first_name = subscriber.first_name existing.first_name = subscriber.first_name
existing.last_name = subscriber.last_name existing.last_name = subscriber.last_name
await self.session.flush() await existing.save()
await self.session.refresh(existing)
return existing return existing
return await self.create_subscriber(subscriber) return await self.create_subscriber(subscriber)
async def create_subscription(self, subscription: domain.Subscription) -> domain.Subscription: async def create_subscription(self, subscription: domain.Subscription) -> domain.Subscription:
self.session.add(subscription) await subscription.save()
await self.session.flush() await subscription.fetch_related('placement', 'subscriber')
await self.session.refresh(subscription, ['placement', 'subscriber'])
return subscription return subscription
async def get_subscription_by_subscriber_and_placement( async def get_subscription_by_subscriber_and_placement(
self, subscriber_id: uuid.UUID, placement_id: uuid.UUID self, subscriber_id: uuid.UUID, placement_id: uuid.UUID
) -> domain.Subscription | None: ) -> domain.Subscription | None:
q = select(domain.Subscription).where( return await domain.Subscription.get_or_none(subscriber_id=subscriber_id, placement_id=placement_id)
domain.Subscription.subscriber_id == subscriber_id,
domain.Subscription.placement_id == placement_id,
)
result = await self.session.execute(q)
return result.scalar_one_or_none()
async def update_subscription(self, subscription: domain.Subscription) -> domain.Subscription: async def update_subscription(self, subscription: domain.Subscription) -> domain.Subscription:
await self.session.flush() await subscription.save()
await self.session.refresh(subscription, ['placement', 'subscriber']) await subscription.fetch_related('placement', 'subscriber')
return subscription return subscription
async def get_active_subscriptions_by_subscriber_and_channel( async def get_active_subscriptions_by_subscriber_and_channel(
self, subscriber_id: uuid.UUID, channel_telegram_id: int self, subscriber_id: uuid.UUID, channel_telegram_id: int
) -> list[domain.Subscription]: ) -> list[domain.Subscription]:
q = ( return (
select(domain.Subscription) await domain.Subscription.filter(
.join(domain.Placement, domain.Subscription.placement_id == domain.Placement.id) subscriber_id=subscriber_id,
.join(domain.TargetChannel, domain.Placement.target_channel_id == domain.TargetChannel.id) target_channel__telegram_id=channel_telegram_id,
.where( status=domain.SubscriptionStatus.ACTIVE,
domain.Subscription.subscriber_id == subscriber_id,
domain.TargetChannel.telegram_id == channel_telegram_id,
domain.Subscription.status == domain.SubscriptionStatus.ACTIVE,
) )
.options(selectinload(domain.Subscription.placement), selectinload(domain.Subscription.subscriber)) .prefetch_related('placement', 'subscriber')
.all()
) )
result = await self.session.execute(q)
return list(result.scalars().all())
async def get_active_subscription_by_subscriber_and_channel( async def get_active_subscription_by_subscriber_and_channel(
self, subscriber_id: uuid.UUID, channel_telegram_id: int self, subscriber_id: uuid.UUID, channel_telegram_id: int
) -> domain.Subscription | None: ) -> domain.Subscription | None:
"""Получить активную подписку пользователя на канал (должна быть только одна благодаря UNIQUE constraint).""" return (
q = ( await domain.Subscription.filter(
select(domain.Subscription) subscriber_id=subscriber_id,
.join(domain.Placement, domain.Subscription.placement_id == domain.Placement.id) target_channel__telegram_id=channel_telegram_id,
.join(domain.TargetChannel, domain.Placement.target_channel_id == domain.TargetChannel.id) status=domain.SubscriptionStatus.ACTIVE,
.where(
domain.Subscription.subscriber_id == subscriber_id,
domain.TargetChannel.telegram_id == channel_telegram_id,
domain.Subscription.status == domain.SubscriptionStatus.ACTIVE,
) )
.options(selectinload(domain.Subscription.placement), selectinload(domain.Subscription.subscriber)) .prefetch_related('placement', 'subscriber')
.first()
) )
result = await self.session.execute(q)
return result.scalar_one_or_none()
async def create_placement_views_history( async def create_placement_views_history(
self, history: domain.PlacementViewsHistory self, history: domain.PlacementViewsHistory
) -> domain.PlacementViewsHistory: ) -> domain.PlacementViewsHistory:
self.session.add(history) await history.save()
await self.session.flush() await history.fetch_related('placement')
await self.session.refresh(history, ['placement'])
return history return history
async def get_views_history( async def get_views_history(
@@ -447,22 +322,17 @@ class Postgres(DatabaseBase):
from_date: datetime.datetime | None = None, from_date: datetime.datetime | None = None,
to_date: datetime.datetime | None = None, to_date: datetime.datetime | None = None,
) -> list[domain.PlacementViewsHistory]: ) -> list[domain.PlacementViewsHistory]:
q = select(domain.PlacementViewsHistory).where(domain.PlacementViewsHistory.placement_id == placement_id) query = domain.PlacementViewsHistory.filter(placement_id=placement_id)
if from_date: if from_date:
q = q.where(domain.PlacementViewsHistory.fetched_at >= from_date) query = query.filter(fetched_at__gte=from_date)
if to_date: if to_date:
q = q.where(domain.PlacementViewsHistory.fetched_at <= to_date) query = query.filter(fetched_at__lte=to_date)
q = q.order_by(domain.PlacementViewsHistory.fetched_at.asc()) return await query.order_by('fetched_at').all()
result = await self.session.execute(q)
return list(result.scalars().all())
async def get_telegram_state(self, telegram_id: int) -> domain.TelegramState | None: async def get_telegram_state(self, telegram_id: int) -> domain.TelegramState | None:
q = select(domain.TelegramState).where(domain.TelegramState.telegram_id == telegram_id) return await domain.TelegramState.get_or_none(telegram_id=telegram_id)
result = await self.session.execute(q)
return result.scalar_one_or_none()
async def set_telegram_state( async def set_telegram_state(
self, telegram_id: int, state: domain.TelegramStateEnum, context: dict[str, typing.Any] self, telegram_id: int, state: domain.TelegramStateEnum, context: dict[str, typing.Any]
@@ -473,16 +343,11 @@ class Postgres(DatabaseBase):
existing.state = state existing.state = state
existing.context = context existing.context = context
existing.updated_at = datetime.datetime.now(datetime.UTC) existing.updated_at = datetime.datetime.now(datetime.UTC)
await self.session.flush() await existing.save()
await self.session.refresh(existing)
return existing return existing
new_state = domain.TelegramState(telegram_id=telegram_id, state=state, context=context) new_state = await domain.TelegramState.create(telegram_id=telegram_id, state=state, context=context)
self.session.add(new_state)
await self.session.flush()
await self.session.refresh(new_state)
return new_state return new_state
async def clear_telegram_state(self, telegram_id: int) -> None: async def clear_telegram_state(self, telegram_id: int) -> None:
q = delete(domain.TelegramState).where(domain.TelegramState.telegram_id == telegram_id) await domain.TelegramState.filter(telegram_id=telegram_id).delete()
await self.session.execute(q)

View File

@@ -1,5 +1,4 @@
__all__ = ( __all__ = (
'Base',
'User', 'User',
'TargetChannel', 'TargetChannel',
'ExternalChannel', 'ExternalChannel',
@@ -31,7 +30,6 @@ __all__ = (
'PlacementNotFound', 'PlacementNotFound',
) )
from .base import Base
from .creative import Creative, CreativeStatus from .creative import Creative, CreativeStatus
from .error import ( from .error import (
ChannelAlreadyExists, ChannelAlreadyExists,

View File

@@ -1,31 +0,0 @@
import datetime
import re
import uuid
from sqlalchemy import func
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
from sqlalchemy.types import DateTime
def _camel_to_snake(name: str) -> str:
name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower()
class Base(DeclarativeBase):
__abstract__ = True
# Use timezone-aware datetime (Postgres TIMESTAMP WITH TIME ZONE)
type_annotation_map = {
datetime.datetime: DateTime(timezone=True),
}
# Авто имя таблиц по названию класса в snake_case
@declared_attr.directive
def __tablename__(cls) -> str:
return _camel_to_snake(cls.__name__)
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())
deleted_at: Mapped[datetime.datetime | None]

View File

@@ -1,27 +1,32 @@
import uuid import enum
from enum import StrEnum
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey from tortoise import fields
from sqlalchemy.orm import Mapped, mapped_column, relationship from tortoise.models import Model
from src import domain
if TYPE_CHECKING:
from .target_channel import TargetChannel
class CreativeStatus(StrEnum): class CreativeStatus(str, enum.Enum):
ACTIVE = 'active' ACTIVE = 'active'
ARCHIVED = 'archived' ARCHIVED = 'archived'
class Creative(domain.Base): class Creative(Model):
name: Mapped[str] id = fields.UUIDField(pk=True)
text: Mapped[str] name = fields.CharField(max_length=255)
status: Mapped[CreativeStatus] text = fields.TextField()
placements_count: Mapped[int] = mapped_column(default=0, server_default='0') status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('user.id'), index=True) placements_count = fields.IntField(default=0)
target_channel_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('target_channel.id'), index=True) user = fields.ForeignKeyField('models.User', related_name='creatives', on_delete=fields.CASCADE, index=True)
target_channel: Mapped['TargetChannel'] = relationship(back_populates='creatives') target_channel = fields.ForeignKeyField(
'models.TargetChannel', related_name='creatives', on_delete=fields.CASCADE, index=True
)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
deleted_at = fields.DatetimeField(null=True)
# Reverse relations:
# placements: fields.ReverseRelation['Placement']
class Meta:
table = 'creative'

View File

@@ -1,31 +1,26 @@
import uuid from tortoise import fields
from typing import TYPE_CHECKING from tortoise.models import Model
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
target_external_channel = Table(
'target_external_channel',
domain.Base.metadata,
Column('target_channel_id', ForeignKey('target_channel.id', ondelete='CASCADE'), primary_key=True),
Column('external_channel_id', ForeignKey('external_channel.id', ondelete='CASCADE'), primary_key=True),
)
class ExternalChannel(domain.Base): class ExternalChannel(Model):
telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True) id = fields.UUIDField(pk=True)
title: Mapped[str] telegram_id = fields.BigIntField(unique=True, index=True)
username: Mapped[str | None] = mapped_column(index=True) title = fields.CharField(max_length=255)
description: Mapped[str | None] username = fields.CharField(max_length=255, null=True, index=True)
subscribers_count: Mapped[int | None] description = fields.TextField(null=True)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('user.id'), index=True) subscribers_count = fields.IntField(null=True)
target_channels: Mapped[list['TargetChannel']] = relationship( user = fields.ForeignKeyField('models.User', related_name='external_channels', on_delete=fields.CASCADE, index=True)
secondary=target_external_channel,
back_populates='external_channels', created_at = fields.DatetimeField(auto_now_add=True)
) updated_at = fields.DatetimeField(auto_now=True)
deleted_at = fields.DatetimeField(null=True)
# Many-to-many с TargetChannel (определено в TargetChannel)
# target_channels: fields.ManyToManyRelation['TargetChannel']
# Reverse relations:
# placements: fields.ReverseRelation['Placement']
class Meta:
table = 'external_channel'

View File

@@ -1,14 +1,17 @@
import datetime from tortoise import fields
import uuid from tortoise.models import Model
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from src import domain
class LoginToken(domain.Base): class LoginToken(Model):
token: Mapped[str] = mapped_column(unique=True, index=True) id = fields.UUIDField(pk=True)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('user.id')) token = fields.CharField(max_length=255, unique=True, index=True)
expires_at: Mapped[datetime.datetime] user = fields.ForeignKeyField('models.User', related_name='login_tokens', on_delete=fields.CASCADE)
used_at: Mapped[datetime.datetime | None] expires_at = fields.DatetimeField()
used_at = fields.DatetimeField(null=True)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
deleted_at = fields.DatetimeField(null=True)
class Meta:
table = 'login_token'

View File

@@ -1,70 +1,68 @@
import datetime import enum
import uuid
from enum import StrEnum
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey from tortoise import fields
from sqlalchemy.orm import Mapped, mapped_column, relationship from tortoise.models import Model
from src import domain
if TYPE_CHECKING:
from .creative import Creative
from .external_channel import ExternalChannel
from .placement_views_history import PlacementViewsHistory
from .subscription import Subscription
from .target_channel import TargetChannel
class PlacementStatus(StrEnum): class PlacementStatus(str, enum.Enum):
PENDING = 'pending' # Ожидается публикация поста PENDING = 'pending' # Ожидается публикация поста
ACTIVE = 'active' # Пост найден и отслеживается ACTIVE = 'active' # Пост найден и отслеживается
DELETED = 'deleted' # Пост удален из канала DELETED = 'deleted' # Пост удален из канала
ARCHIVED = 'archived' # Вручную архивировано ARCHIVED = 'archived' # Вручную архивировано
class InviteLinkType(StrEnum): class InviteLinkType(str, enum.Enum):
PUBLIC = 'public' # открытая ссылка PUBLIC = 'public' # открытая ссылка
APPROVAL = 'approval' # с одобрением ботом APPROVAL = 'approval' # с одобрением ботом
class PostViewsAvailability(StrEnum): class PostViewsAvailability(str, enum.Enum):
UNKNOWN = 'unknown' # Ещё не проверялось UNKNOWN = 'unknown' # Ещё не проверялось
AVAILABLE = 'available' # Просмотры доступны AVAILABLE = 'available' # Просмотры доступны
UNAVAILABLE = 'unavailable' # Нет доступа / битая ссылка / приватный канал UNAVAILABLE = 'unavailable' # Нет доступа / битая ссылка / приватный канал
MANUAL = 'manual' # Просмотры вводятся вручную MANUAL = 'manual' # Просмотры вводятся вручную
class Placement(domain.Base): class Placement(Model):
# Основные поля id = fields.UUIDField(pk=True)
target_channel_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('target_channel.id'), index=True)
external_channel_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('external_channel.id'), index=True) # Foreign keys
creative_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('creative.id'), index=True) target_channel = fields.ForeignKeyField(
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('user.id'), index=True) 'models.TargetChannel', related_name='placements', on_delete=fields.CASCADE, index=True
)
external_channel = fields.ForeignKeyField(
'models.ExternalChannel', related_name='placements', on_delete=fields.CASCADE, index=True
)
creative = fields.ForeignKeyField('models.Creative', related_name='placements', on_delete=fields.CASCADE, index=True)
user = fields.ForeignKeyField('models.User', related_name='placements', on_delete=fields.CASCADE, index=True)
# Данные закупа # Данные закупа
placement_date: Mapped[datetime.datetime] # дата размещения (планируемая или фактическая) placement_date = fields.DatetimeField()
cost: Mapped[float | None] # стоимость закупа cost = fields.FloatField(null=True)
comment: Mapped[str | None] # комментарий comment = fields.TextField(null=True)
ad_post_url: Mapped[str | None] # ссылка на рекламное сообщение во внешнем канале ad_post_url = fields.CharField(max_length=512, null=True)
invite_link_type: Mapped[InviteLinkType] # тип пригласительной ссылки invite_link_type = fields.CharEnumField(InviteLinkType)
invite_link: Mapped[str] # уникальная пригласительная ссылка invite_link = fields.CharField(max_length=512)
external_channel_message_id: Mapped[int | None] # message_id в Telegram после нахождения поста external_channel_message_id = fields.IntField(null=True)
# Статус # Статус
status: Mapped[PlacementStatus] = mapped_column(default=PlacementStatus.ACTIVE) status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE)
# Метрики (будут обновляться из Telegram API) # Метрики
subscriptions_count: Mapped[int] = mapped_column(default=0, server_default='0') subscriptions_count = fields.IntField(default=0)
views_count: Mapped[int | None] # последние известные просмотры (кэш из последнего snapshot) views_count = fields.IntField(null=True)
# Статус доступности просмотров # Статус доступности просмотров
views_availability: Mapped[PostViewsAvailability] = mapped_column(default=PostViewsAvailability.UNKNOWN) views_availability = fields.CharEnumField(PostViewsAvailability, default=PostViewsAvailability.UNKNOWN)
last_views_fetch_at: Mapped[datetime.datetime | None] # когда последний раз получали просмотры last_views_fetch_at = fields.DatetimeField(null=True)
# Relationships created_at = fields.DatetimeField(auto_now_add=True)
target_channel: Mapped['TargetChannel'] = relationship() updated_at = fields.DatetimeField(auto_now=True)
external_channel: Mapped['ExternalChannel'] = relationship() deleted_at = fields.DatetimeField(null=True)
creative: Mapped['Creative'] = relationship()
subscriptions: Mapped[list['Subscription']] = relationship(back_populates='placement') # Reverse relations:
views_histories: Mapped[list['PlacementViewsHistory']] = relationship(back_populates='placement') # subscriptions: fields.ReverseRelation['Subscription']
# views_histories: fields.ReverseRelation['PlacementViewsHistory']
class Meta:
table = 'placement'

View File

@@ -1,19 +1,20 @@
import datetime from tortoise import fields
import uuid from tortoise.models import Model
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 .placement import Placement
class PlacementViewsHistory(domain.Base): class PlacementViewsHistory(Model):
placement_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('placement.id'), index=True) id = fields.UUIDField(pk=True)
views_count: Mapped[int] # Количество просмотров на момент снимка
fetched_at: Mapped[datetime.datetime] = mapped_column(index=True)
placement: Mapped['Placement'] = relationship(back_populates='views_histories') placement = fields.ForeignKeyField(
'models.Placement', related_name='views_histories', on_delete=fields.CASCADE, index=True
)
views_count = fields.IntField() # Количество просмотров на момент снимка
fetched_at = fields.DatetimeField(index=True)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
deleted_at = fields.DatetimeField(null=True)
class Meta:
table = 'placement_views_history'

View File

@@ -1,18 +1,20 @@
from typing import TYPE_CHECKING from tortoise import fields
from tortoise.models import Model
from sqlalchemy import BigInteger
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src import domain
if TYPE_CHECKING:
from .subscription import Subscription
class Subscriber(domain.Base): class Subscriber(Model):
telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True) id = fields.UUIDField(pk=True)
username: Mapped[str | None] telegram_id = fields.BigIntField(unique=True, index=True)
first_name: Mapped[str | None] username = fields.CharField(max_length=255, null=True)
last_name: Mapped[str | None] first_name = fields.CharField(max_length=255, null=True)
last_name = fields.CharField(max_length=255, null=True)
subscriptions: Mapped[list['Subscription']] = relationship(back_populates='subscriber') created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
deleted_at = fields.DatetimeField(null=True)
# Reverse relations:
# subscriptions: fields.ReverseRelation['Subscription']
class Meta:
table = 'subscriber'

View File

@@ -1,31 +1,34 @@
import datetime import enum
import uuid
from enum import StrEnum
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey from tortoise import fields
from sqlalchemy.orm import Mapped, mapped_column, relationship from tortoise.models import Model
from src import domain
if TYPE_CHECKING:
from .placement import Placement
from .subscriber import Subscriber
class SubscriptionStatus(StrEnum): class SubscriptionStatus(str, enum.Enum):
ACTIVE = 'active' ACTIVE = 'active'
UNSUBSCRIBED = 'unsubscribed' UNSUBSCRIBED = 'unsubscribed'
class Subscription(domain.Base): class Subscription(Model):
placement_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('placement.id'), index=True) id = fields.UUIDField(pk=True)
subscriber_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('subscriber.id'), index=True)
target_channel_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('target_channel.id'), index=True)
invite_link: Mapped[str] = mapped_column(index=True) # По которой пришёл пользователь
status: Mapped[SubscriptionStatus] = mapped_column(default=SubscriptionStatus.ACTIVE)
unsubscribed_at: Mapped[datetime.datetime | None]
# Relationships placement = fields.ForeignKeyField(
placement: Mapped['Placement'] = relationship(back_populates='subscriptions') 'models.Placement', related_name='subscriptions', on_delete=fields.CASCADE, index=True
subscriber: Mapped['Subscriber'] = relationship(back_populates='subscriptions') )
subscriber = fields.ForeignKeyField(
'models.Subscriber', related_name='subscriptions', on_delete=fields.CASCADE, index=True
)
target_channel = fields.ForeignKeyField(
'models.TargetChannel', related_name='subscriptions', on_delete=fields.CASCADE, index=True
)
invite_link = fields.CharField(max_length=512, index=True)
status = fields.CharEnumField(SubscriptionStatus, default=SubscriptionStatus.ACTIVE)
unsubscribed_at = fields.DatetimeField(null=True)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
deleted_at = fields.DatetimeField(null=True)
class Meta:
table = 'subscription'

View File

@@ -1,36 +1,39 @@
import uuid import enum
from enum import StrEnum
from typing import TYPE_CHECKING
from sqlalchemy import BigInteger, ForeignKey from tortoise import fields
from sqlalchemy.orm import Mapped, mapped_column, relationship from tortoise.models import Model
from src import domain
if TYPE_CHECKING:
from . import ExternalChannel
from .creative import Creative
from .user import User
class TargetChannelStatus(StrEnum): class TargetChannelStatus(str, enum.Enum):
ACTIVE = 'active' ACTIVE = 'active'
INACTIVE = 'inactive' INACTIVE = 'inactive'
ARCHIVED = 'archived' ARCHIVED = 'archived'
class TargetChannel(domain.Base): class TargetChannel(Model):
telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True) id = fields.UUIDField(pk=True)
title: Mapped[str] telegram_id = fields.BigIntField(unique=True, index=True)
username: Mapped[str | None] = mapped_column(index=True) title = fields.CharField(max_length=255)
status: Mapped[TargetChannelStatus] = mapped_column(default=TargetChannelStatus.ACTIVE) username = fields.CharField(max_length=255, null=True, index=True)
status = fields.CharEnumField(TargetChannelStatus, default=TargetChannelStatus.ACTIVE)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('user.id'), index=True) user = fields.ForeignKeyField('models.User', related_name='target_channels', on_delete=fields.CASCADE, index=True)
user: Mapped['User'] = relationship(back_populates='target_channels')
external_channels: Mapped[list['ExternalChannel']] = relationship( created_at = fields.DatetimeField(auto_now_add=True)
secondary='target_external_channel', updated_at = fields.DatetimeField(auto_now=True)
back_populates='target_channels', deleted_at = fields.DatetimeField(null=True)
# Many-to-many с ExternalChannel через таблицу target_external_channel
external_channels: fields.ManyToManyRelation['ExternalChannel'] = fields.ManyToManyField(
'models.ExternalChannel',
related_name='target_channels',
through='target_external_channel',
) )
creatives: Mapped[list['Creative']] = relationship(back_populates='target_channel') # Reverse relations:
# creatives: fields.ReverseRelation['Creative']
# placements: fields.ReverseRelation['Placement']
# subscriptions: fields.ReverseRelation['Subscription']
class Meta:
table = 'target_channel'

View File

@@ -1,11 +1,7 @@
import enum import enum
from typing import Any
from sqlalchemy import BigInteger, Enum from tortoise import fields
from sqlalchemy.dialects.postgresql import JSONB from tortoise.models import Model
from sqlalchemy.orm import Mapped, mapped_column
from . import Base
class TelegramStateEnum(str, enum.Enum): class TelegramStateEnum(str, enum.Enum):
@@ -16,9 +12,17 @@ class TelegramStateEnum(str, enum.Enum):
CREATIVE_WAITING_TEXT = 'creative_waiting_text' CREATIVE_WAITING_TEXT = 'creative_waiting_text'
class TelegramState(Base): class TelegramState(Model):
"""Хранит текущее состояние разговора пользователя с ботом для multi-step flows.""" """Хранит текущее состояние разговора пользователя с ботом для multi-step flows."""
telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False, index=True) id = fields.UUIDField(pk=True)
state: Mapped[TelegramStateEnum] = mapped_column(Enum(TelegramStateEnum), nullable=False) telegram_id = fields.BigIntField(unique=True, index=True)
context: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) state = fields.CharEnumField(TelegramStateEnum)
context = fields.JSONField(default=dict)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
deleted_at = fields.DatetimeField(null=True)
class Meta:
table = 'telegram_state'

View File

@@ -1,15 +1,22 @@
from typing import TYPE_CHECKING from tortoise import fields
from tortoise.models import Model
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src import domain
if TYPE_CHECKING:
from . import TargetChannel
class User(domain.Base): class User(Model):
telegram_id: Mapped[int] = mapped_column(unique=True, index=True) id = fields.UUIDField(pk=True)
username: Mapped[str | None] telegram_id = fields.BigIntField(unique=True, index=True)
username = fields.CharField(max_length=255, null=True)
target_channels: Mapped[list['TargetChannel']] = relationship(back_populates='user') created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
deleted_at = fields.DatetimeField(null=True)
# Reverse relations (автоматически создаются):
# target_channels: fields.ReverseRelation['TargetChannel']
# login_tokens: fields.ReverseRelation['LoginToken']
# creatives: fields.ReverseRelation['Creative']
# placements: fields.ReverseRelation['Placement']
# external_channels: fields.ReverseRelation['ExternalChannel']
class Meta:
table = 'user'

56
uv.lock generated
View File

@@ -92,6 +92,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
] ]
[[package]]
name = "aiosqlite"
version = "0.21.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload-time = "2025-02-03T07:30:16.235Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" },
]
[[package]] [[package]]
name = "alembic" name = "alembic"
version = "1.17.1" version = "1.17.1"
@@ -376,6 +388,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
] ]
[[package]]
name = "iso8601"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/f3/ef59cee614d5e0accf6fd0cbba025b93b272e626ca89fb70a3e9187c5d15/iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", size = 6522, upload-time = "2023-10-03T00:25:39.317Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" },
]
[[package]] [[package]]
name = "lxml" name = "lxml"
version = "6.0.2" version = "6.0.2"
@@ -798,6 +819,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
] ]
[[package]]
name = "pypika-tortoise"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cc/28/86ec1bccb2609d20349def444ef9dfe84aeccc984caa62f4634d50fee164/pypika_tortoise-0.6.3.tar.gz", hash = "sha256:6e17f00e77e78468836cb5c63eb6dc01445f83b1167e4f29f1c678949179c079", size = 80689, upload-time = "2025-11-26T22:07:08.293Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/6a/da5ba6830dd16cea2804163a2cecc1b2a85b8e06c61f0abb0477069d013d/pypika_tortoise-0.6.3-py3-none-any.whl", hash = "sha256:762e508093f4d73d3654cdde5bce8f92f8f41d999993c44d972d4f1703a663df", size = 46918, upload-time = "2025-11-26T22:07:07.052Z" },
]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "8.4.2" version = "8.4.2"
@@ -844,6 +874,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
] ]
[[package]]
name = "pytz"
version = "2025.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.14.4" version = "0.14.4"
@@ -942,6 +981,7 @@ dependencies = [
{ name = "pyjwt" }, { name = "pyjwt" },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "sqlalchemy", extra = ["asyncio"] }, { name = "sqlalchemy", extra = ["asyncio"] },
{ name = "tortoise-orm" },
{ name = "uvicorn" }, { name = "uvicorn" },
] ]
@@ -969,6 +1009,7 @@ requires-dist = [
{ name = "pyjwt", specifier = ">=2.10.1" }, { name = "pyjwt", specifier = ">=2.10.1" },
{ name = "python-multipart", specifier = ">=0.0.20" }, { name = "python-multipart", specifier = ">=0.0.20" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.38" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.38" },
{ name = "tortoise-orm", specifier = ">=0.25.1" },
{ name = "uvicorn", specifier = ">=0.38.0" }, { name = "uvicorn", specifier = ">=0.38.0" },
] ]
@@ -983,6 +1024,21 @@ dev = [
{ name = "vulture", specifier = ">=2.14" }, { name = "vulture", specifier = ">=2.14" },
] ]
[[package]]
name = "tortoise-orm"
version = "0.25.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiosqlite" },
{ name = "iso8601", marker = "python_full_version < '4'" },
{ name = "pypika-tortoise", marker = "python_full_version < '4'" },
{ name = "pytz" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d7/9b/de966810021fa773fead258efd8deea2bb73bb12479e27f288bd8ceb8763/tortoise_orm-0.25.1.tar.gz", hash = "sha256:4d5bfd13d5750935ffe636a6b25597c5c8f51c47e5b72d7509d712eda1a239fe", size = 128341, upload-time = "2025-06-05T10:43:31.058Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/55/2bda7f4445f4c07b734385b46d1647a388d05160cf5b8714a713e8709378/tortoise_orm-0.25.1-py3-none-any.whl", hash = "sha256:df0ef7e06eb0650a7e5074399a51ee6e532043308c612db2cac3882486a3fd9f", size = 167723, upload-time = "2025-06-05T10:43:29.309Z" },
]
[[package]] [[package]]
name = "ty" name = "ty"
version = "0.0.1a25" version = "0.0.1a25"