feat: типизация для tortoise ORM
This commit is contained in:
@@ -5,7 +5,11 @@ from sqlalchemy import engine_from_config, pool
|
|||||||
|
|
||||||
from src.config import settings
|
from src.config import settings
|
||||||
from src.domain import * # noqa
|
from src.domain import * # noqa
|
||||||
from src.domain import Base
|
|
||||||
|
try:
|
||||||
|
from src.domain import Base as SqlBase # type: ignore[attr-defined]
|
||||||
|
except ImportError:
|
||||||
|
SqlBase = None
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
@@ -13,7 +17,7 @@ if config.config_file_name:
|
|||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
config.set_main_option('sqlalchemy.url', f'{settings.db.URL}?async_fallback=True')
|
config.set_main_option('sqlalchemy.url', f'{settings.db.URL}?async_fallback=True')
|
||||||
target_metadata = Base.metadata
|
target_metadata = SqlBase.metadata if SqlBase else None
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
def run_migrations_offline() -> None:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ dependencies = [
|
|||||||
"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",
|
"tortoise-orm>=0.25.1",
|
||||||
|
"tortoise-orm-stubs>=1.0.2",
|
||||||
"uvicorn>=0.38.0",
|
"uvicorn>=0.38.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def parse_env_file(file_path: Path) -> set[str]:
|
|
||||||
if not file_path.exists():
|
|
||||||
return set()
|
|
||||||
|
|
||||||
variables = set()
|
|
||||||
with open(file_path) as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if line and not line.startswith('#') and '=' in line:
|
|
||||||
variables.add(line.split('=', 1)[0].strip())
|
|
||||||
return variables
|
|
||||||
|
|
||||||
|
|
||||||
def get_pydantic_settings_fields() -> tuple[set[str], set[str]]:
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from src.config import Settings
|
|
||||||
|
|
||||||
all_fields, required_fields = set(), set()
|
|
||||||
|
|
||||||
def extract_fields(model: type[BaseModel], prefix: str = '') -> None:
|
|
||||||
for field_name, field_info in model.model_fields.items():
|
|
||||||
env_var_name = f'{prefix}{field_name}'.upper()
|
|
||||||
field_type = field_info.annotation
|
|
||||||
if hasattr(field_type, '__origin__'):
|
|
||||||
field_type = field_type.__args__[0] if field_type.__args__ else field_type
|
|
||||||
|
|
||||||
try:
|
|
||||||
if isinstance(field_type, type) and issubclass(field_type, BaseModel):
|
|
||||||
extract_fields(field_type, prefix=f'{env_var_name}__')
|
|
||||||
else:
|
|
||||||
all_fields.add(env_var_name)
|
|
||||||
if field_info.is_required():
|
|
||||||
required_fields.add(env_var_name)
|
|
||||||
except TypeError:
|
|
||||||
all_fields.add(env_var_name)
|
|
||||||
if field_info.is_required():
|
|
||||||
required_fields.add(env_var_name)
|
|
||||||
|
|
||||||
extract_fields(Settings)
|
|
||||||
return all_fields, required_fields
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
project_root = Path(__file__).parent.parent.parent
|
|
||||||
env_file = project_root / '.env'
|
|
||||||
env_example_file = project_root / '.env.example'
|
|
||||||
|
|
||||||
if not env_example_file.exists():
|
|
||||||
print('❌ Файл .env.example не найден')
|
|
||||||
return 1
|
|
||||||
|
|
||||||
env_vars = parse_env_file(env_file)
|
|
||||||
env_example_vars = parse_env_file(env_example_file)
|
|
||||||
all_pydantic_fields, required_pydantic_fields = get_pydantic_settings_fields()
|
|
||||||
|
|
||||||
print('🔍 Проверка соответствия .env и .env.example\n')
|
|
||||||
has_errors = False
|
|
||||||
|
|
||||||
missing_in_env = env_example_vars - env_vars
|
|
||||||
if missing_in_env:
|
|
||||||
has_errors = True
|
|
||||||
print('❌ Переменные из .env.example отсутствуют в .env:')
|
|
||||||
for var in sorted(missing_in_env):
|
|
||||||
print(f' - {var}')
|
|
||||||
print()
|
|
||||||
|
|
||||||
extra_in_env = env_vars - env_example_vars
|
|
||||||
if extra_in_env:
|
|
||||||
has_errors = True
|
|
||||||
print('⚠️ Переменные в .env, которых нет в .env.example:')
|
|
||||||
for var in sorted(extra_in_env):
|
|
||||||
print(f' - {var}')
|
|
||||||
print()
|
|
||||||
|
|
||||||
missing_in_pydantic = env_example_vars - all_pydantic_fields
|
|
||||||
if missing_in_pydantic:
|
|
||||||
has_errors = True
|
|
||||||
print('❌ Переменные из .env.example не определены в Pydantic Settings (src/config.py):')
|
|
||||||
for var in sorted(missing_in_pydantic):
|
|
||||||
print(f' - {var}')
|
|
||||||
print()
|
|
||||||
|
|
||||||
missing_in_example = required_pydantic_fields - env_example_vars
|
|
||||||
if missing_in_example:
|
|
||||||
has_errors = True
|
|
||||||
print('❌ Обязательные поля Pydantic Settings отсутствуют в .env.example:')
|
|
||||||
for var in sorted(missing_in_example):
|
|
||||||
print(f' - {var}')
|
|
||||||
print()
|
|
||||||
|
|
||||||
if not has_errors:
|
|
||||||
print('✅ Все проверки пройдены успешно!')
|
|
||||||
print(f' - Переменных в .env.example: {len(env_example_vars)}')
|
|
||||||
print(f' - Переменных в .env: {len(env_vars)}')
|
|
||||||
print(
|
|
||||||
f' - Полей в Pydantic Settings (всего/обязательных): {len(all_pydantic_fields)}/{len(required_pydantic_fields)}'
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
print('❌ Обнаружены несоответствия')
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -44,13 +44,13 @@ 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')
|
||||||
|
|
||||||
filters = {'user_id': user_id}
|
query = domain.TargetChannel.filter(user_id=user_id)
|
||||||
if channel_id:
|
if channel_id:
|
||||||
filters['id'] = channel_id
|
query = query.filter(id=channel_id)
|
||||||
if telegram_id:
|
if telegram_id:
|
||||||
filters['telegram_id'] = telegram_id
|
query = query.filter(telegram_id=telegram_id)
|
||||||
|
|
||||||
return await domain.TargetChannel.get_or_none(**filters)
|
return await query.first()
|
||||||
|
|
||||||
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)
|
||||||
@@ -78,20 +78,22 @@ class Postgres(DatabaseBase):
|
|||||||
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:
|
||||||
filters = {'user_id': user_id}
|
query = domain.ExternalChannel.filter(user_id=user_id)
|
||||||
if channel_id:
|
if channel_id:
|
||||||
filters['id'] = channel_id
|
query = query.filter(id=channel_id)
|
||||||
if telegram_id:
|
if telegram_id:
|
||||||
filters['telegram_id'] = telegram_id
|
query = query.filter(telegram_id=telegram_id)
|
||||||
|
|
||||||
return await domain.ExternalChannel.get_or_none(**filters)
|
return await query.first()
|
||||||
|
|
||||||
async def create_external_channel(self, channel: domain.ExternalChannel) -> domain.ExternalChannel:
|
async def create_external_channel(self, channel: domain.ExternalChannel) -> domain.ExternalChannel:
|
||||||
await channel.save()
|
await channel.save()
|
||||||
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]:
|
||||||
target = await domain.TargetChannel.get_or_none(id=target_channel_id).prefetch_related('external_channels')
|
target = await (
|
||||||
|
domain.TargetChannel.filter(id=target_channel_id).prefetch_related('external_channels').first()
|
||||||
|
)
|
||||||
if not target:
|
if not target:
|
||||||
return []
|
return []
|
||||||
return await target.external_channels.all()
|
return await target.external_channels.all()
|
||||||
@@ -110,7 +112,7 @@ class Postgres(DatabaseBase):
|
|||||||
for target_id in target_channel_ids:
|
for target_id in target_channel_ids:
|
||||||
target_channel = await domain.TargetChannel.get_or_none(id=target_id)
|
target_channel = await domain.TargetChannel.get_or_none(id=target_id)
|
||||||
if target_channel:
|
if target_channel:
|
||||||
await external_channel.external_channels.add(target_channel)
|
await external_channel.target_channels.add(target_channel)
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
11
src/domain/base.py
Normal file
11
src/domain/base.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from tortoise import fields
|
||||||
|
from tortoise.models import Model
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampedModel(Model):
|
||||||
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
|
updated_at = fields.DatetimeField(auto_now=True)
|
||||||
|
deleted_at = fields.DatetimeField(null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import enum
|
import enum
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
from tortoise.models import Model
|
|
||||||
|
from .base import TimestampedModel
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .target_channel import TargetChannel
|
||||||
|
from .user import User
|
||||||
|
|
||||||
|
|
||||||
class CreativeStatus(str, enum.Enum):
|
class CreativeStatus(str, enum.Enum):
|
||||||
@@ -9,24 +16,23 @@ class CreativeStatus(str, enum.Enum):
|
|||||||
ARCHIVED = 'archived'
|
ARCHIVED = 'archived'
|
||||||
|
|
||||||
|
|
||||||
class Creative(Model):
|
class Creative(TimestampedModel):
|
||||||
id = fields.UUIDField(pk=True)
|
id = fields.UUIDField(pk=True)
|
||||||
name = fields.CharField(max_length=255)
|
name = fields.CharField(max_length=255)
|
||||||
text = fields.TextField()
|
text = fields.TextField()
|
||||||
status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE)
|
status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE)
|
||||||
placements_count = fields.IntField(default=0)
|
placements_count = fields.IntField(default=0)
|
||||||
|
|
||||||
user = fields.ForeignKeyField('models.User', related_name='creatives', on_delete=fields.CASCADE, index=True)
|
user: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField(
|
||||||
target_channel = fields.ForeignKeyField(
|
'models.User', related_name='creatives', on_delete=fields.CASCADE, index=True
|
||||||
|
)
|
||||||
|
target_channel: fields.ForeignKeyRelation['TargetChannel'] = fields.ForeignKeyField(
|
||||||
'models.TargetChannel', related_name='creatives', on_delete=fields.CASCADE, index=True
|
'models.TargetChannel', related_name='creatives', on_delete=fields.CASCADE, index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
created_at = fields.DatetimeField(auto_now_add=True)
|
if TYPE_CHECKING:
|
||||||
updated_at = fields.DatetimeField(auto_now=True)
|
user_id: UUID
|
||||||
deleted_at = fields.DatetimeField(null=True)
|
target_channel_id: UUID
|
||||||
|
|
||||||
# Reverse relations:
|
|
||||||
# placements: fields.ReverseRelation['Placement']
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = 'creative'
|
table = 'creative'
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
from tortoise.models import Model
|
|
||||||
|
from .base import TimestampedModel
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .target_channel import TargetChannel
|
||||||
|
from .user import User
|
||||||
|
|
||||||
|
|
||||||
class ExternalChannel(Model):
|
class ExternalChannel(TimestampedModel):
|
||||||
id = fields.UUIDField(pk=True)
|
id = fields.UUIDField(pk=True)
|
||||||
telegram_id = fields.BigIntField(unique=True, index=True)
|
telegram_id = fields.BigIntField(unique=True, index=True)
|
||||||
title = fields.CharField(max_length=255)
|
title = fields.CharField(max_length=255)
|
||||||
@@ -10,17 +18,14 @@ class ExternalChannel(Model):
|
|||||||
description = fields.TextField(null=True)
|
description = fields.TextField(null=True)
|
||||||
subscribers_count = fields.IntField(null=True)
|
subscribers_count = fields.IntField(null=True)
|
||||||
|
|
||||||
user = fields.ForeignKeyField('models.User', related_name='external_channels', on_delete=fields.CASCADE, index=True)
|
user: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField(
|
||||||
|
'models.User', related_name='external_channels', on_delete=fields.CASCADE, index=True
|
||||||
|
)
|
||||||
|
|
||||||
created_at = fields.DatetimeField(auto_now_add=True)
|
target_channels: fields.ManyToManyRelation['TargetChannel']
|
||||||
updated_at = fields.DatetimeField(auto_now=True)
|
|
||||||
deleted_at = fields.DatetimeField(null=True)
|
|
||||||
|
|
||||||
# Many-to-many с TargetChannel (определено в TargetChannel)
|
if TYPE_CHECKING:
|
||||||
# target_channels: fields.ManyToManyRelation['TargetChannel']
|
user_id: UUID
|
||||||
|
|
||||||
# Reverse relations:
|
|
||||||
# placements: fields.ReverseRelation['Placement']
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = 'external_channel'
|
table = 'external_channel'
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
from tortoise.models import Model
|
|
||||||
|
from .base import TimestampedModel
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .user import User
|
||||||
|
|
||||||
|
|
||||||
class LoginToken(Model):
|
class LoginToken(TimestampedModel):
|
||||||
id = fields.UUIDField(pk=True)
|
id = fields.UUIDField(pk=True)
|
||||||
token = fields.CharField(max_length=255, unique=True, index=True)
|
token = fields.CharField(max_length=255, unique=True, index=True)
|
||||||
user = fields.ForeignKeyField('models.User', related_name='login_tokens', on_delete=fields.CASCADE)
|
user: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField(
|
||||||
|
'models.User', related_name='login_tokens', on_delete=fields.CASCADE
|
||||||
|
)
|
||||||
expires_at = fields.DatetimeField()
|
expires_at = fields.DatetimeField()
|
||||||
used_at = fields.DatetimeField(null=True)
|
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:
|
class Meta:
|
||||||
table = 'login_token'
|
table = 'login_token'
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
user_id: UUID
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import enum
|
import enum
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
from tortoise.models import Model
|
|
||||||
|
from .base import TimestampedModel
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .creative import Creative
|
||||||
|
from .external_channel import ExternalChannel
|
||||||
|
from .target_channel import TargetChannel
|
||||||
|
from .user import User
|
||||||
|
|
||||||
|
|
||||||
class PlacementStatus(str, enum.Enum):
|
class PlacementStatus(str, enum.Enum):
|
||||||
@@ -23,20 +32,8 @@ class PostViewsAvailability(str, enum.Enum):
|
|||||||
MANUAL = 'manual' # Просмотры вводятся вручную
|
MANUAL = 'manual' # Просмотры вводятся вручную
|
||||||
|
|
||||||
|
|
||||||
class Placement(Model):
|
class Placement(TimestampedModel):
|
||||||
id = fields.UUIDField(pk=True)
|
id = fields.UUIDField(pk=True)
|
||||||
|
|
||||||
# Foreign keys
|
|
||||||
target_channel = fields.ForeignKeyField(
|
|
||||||
'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 = fields.DatetimeField()
|
placement_date = fields.DatetimeField()
|
||||||
cost = fields.FloatField(null=True)
|
cost = fields.FloatField(null=True)
|
||||||
comment = fields.TextField(null=True)
|
comment = fields.TextField(null=True)
|
||||||
@@ -45,24 +42,32 @@ class Placement(Model):
|
|||||||
invite_link = fields.CharField(max_length=512)
|
invite_link = fields.CharField(max_length=512)
|
||||||
external_channel_message_id = fields.IntField(null=True)
|
external_channel_message_id = fields.IntField(null=True)
|
||||||
|
|
||||||
# Статус
|
|
||||||
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE)
|
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE)
|
||||||
|
|
||||||
# Метрики
|
|
||||||
subscriptions_count = fields.IntField(default=0)
|
subscriptions_count = fields.IntField(default=0)
|
||||||
views_count = fields.IntField(null=True)
|
views_count = fields.IntField(null=True)
|
||||||
|
|
||||||
# Статус доступности просмотров
|
|
||||||
views_availability = fields.CharEnumField(PostViewsAvailability, default=PostViewsAvailability.UNKNOWN)
|
views_availability = fields.CharEnumField(PostViewsAvailability, default=PostViewsAvailability.UNKNOWN)
|
||||||
last_views_fetch_at = fields.DatetimeField(null=True)
|
last_views_fetch_at = fields.DatetimeField(null=True)
|
||||||
|
|
||||||
created_at = fields.DatetimeField(auto_now_add=True)
|
target_channel: fields.ForeignKeyRelation['TargetChannel'] = fields.ForeignKeyField(
|
||||||
updated_at = fields.DatetimeField(auto_now=True)
|
'models.TargetChannel', related_name='placements', on_delete=fields.CASCADE, index=True
|
||||||
deleted_at = fields.DatetimeField(null=True)
|
)
|
||||||
|
external_channel: fields.ForeignKeyRelation['ExternalChannel'] = fields.ForeignKeyField(
|
||||||
|
'models.ExternalChannel', related_name='placements', on_delete=fields.CASCADE, index=True
|
||||||
|
)
|
||||||
|
creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
|
||||||
|
'models.Creative', related_name='placements', on_delete=fields.CASCADE, index=True
|
||||||
|
)
|
||||||
|
user: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField(
|
||||||
|
'models.User', related_name='placements', on_delete=fields.CASCADE, index=True
|
||||||
|
)
|
||||||
|
|
||||||
# Reverse relations:
|
if TYPE_CHECKING:
|
||||||
# subscriptions: fields.ReverseRelation['Subscription']
|
target_channel_id: UUID
|
||||||
# views_histories: fields.ReverseRelation['PlacementViewsHistory']
|
external_channel_id: UUID
|
||||||
|
creative_id: UUID
|
||||||
|
user_id: UUID
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = 'placement'
|
table = 'placement'
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
from tortoise.models import Model
|
|
||||||
|
from .base import TimestampedModel
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .placement import Placement
|
||||||
|
|
||||||
|
|
||||||
class PlacementViewsHistory(Model):
|
class PlacementViewsHistory(TimestampedModel):
|
||||||
id = fields.UUIDField(pk=True)
|
id = fields.UUIDField(pk=True)
|
||||||
|
|
||||||
placement = fields.ForeignKeyField(
|
|
||||||
'models.Placement', related_name='views_histories', on_delete=fields.CASCADE, index=True
|
|
||||||
)
|
|
||||||
|
|
||||||
views_count = fields.IntField() # Количество просмотров на момент снимка
|
views_count = fields.IntField() # Количество просмотров на момент снимка
|
||||||
fetched_at = fields.DatetimeField(index=True)
|
fetched_at = fields.DatetimeField(index=True)
|
||||||
|
|
||||||
created_at = fields.DatetimeField(auto_now_add=True)
|
placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField(
|
||||||
updated_at = fields.DatetimeField(auto_now=True)
|
'models.Placement', related_name='views_histories', on_delete=fields.CASCADE, index=True
|
||||||
deleted_at = fields.DatetimeField(null=True)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
placement_id: UUID
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = 'placement_views_history'
|
table = 'placement_views_history'
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
from tortoise.models import Model
|
|
||||||
|
from .base import TimestampedModel
|
||||||
|
|
||||||
|
|
||||||
class Subscriber(Model):
|
class Subscriber(TimestampedModel):
|
||||||
id = fields.UUIDField(pk=True)
|
id = fields.UUIDField(pk=True)
|
||||||
telegram_id = fields.BigIntField(unique=True, index=True)
|
telegram_id = fields.BigIntField(unique=True, index=True)
|
||||||
username = fields.CharField(max_length=255, null=True)
|
username = fields.CharField(max_length=255, null=True)
|
||||||
first_name = fields.CharField(max_length=255, null=True)
|
first_name = fields.CharField(max_length=255, null=True)
|
||||||
last_name = fields.CharField(max_length=255, null=True)
|
last_name = fields.CharField(max_length=255, null=True)
|
||||||
|
|
||||||
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:
|
class Meta:
|
||||||
table = 'subscriber'
|
table = 'subscriber'
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import enum
|
import enum
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
from tortoise.models import Model
|
|
||||||
|
from .base import TimestampedModel
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .placement import Placement
|
||||||
|
from .subscriber import Subscriber
|
||||||
|
from .target_channel import TargetChannel
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionStatus(str, enum.Enum):
|
class SubscriptionStatus(str, enum.Enum):
|
||||||
@@ -9,26 +17,26 @@ class SubscriptionStatus(str, enum.Enum):
|
|||||||
UNSUBSCRIBED = 'unsubscribed'
|
UNSUBSCRIBED = 'unsubscribed'
|
||||||
|
|
||||||
|
|
||||||
class Subscription(Model):
|
class Subscription(TimestampedModel):
|
||||||
id = fields.UUIDField(pk=True)
|
id = fields.UUIDField(pk=True)
|
||||||
|
|
||||||
placement = fields.ForeignKeyField(
|
|
||||||
'models.Placement', related_name='subscriptions', on_delete=fields.CASCADE, index=True
|
|
||||||
)
|
|
||||||
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)
|
invite_link = fields.CharField(max_length=512, index=True)
|
||||||
status = fields.CharEnumField(SubscriptionStatus, default=SubscriptionStatus.ACTIVE)
|
status = fields.CharEnumField(SubscriptionStatus, default=SubscriptionStatus.ACTIVE)
|
||||||
unsubscribed_at = fields.DatetimeField(null=True)
|
unsubscribed_at = fields.DatetimeField(null=True)
|
||||||
|
|
||||||
created_at = fields.DatetimeField(auto_now_add=True)
|
placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField(
|
||||||
updated_at = fields.DatetimeField(auto_now=True)
|
'models.Placement', related_name='subscriptions', on_delete=fields.CASCADE, index=True
|
||||||
deleted_at = fields.DatetimeField(null=True)
|
)
|
||||||
|
subscriber: fields.ForeignKeyRelation['Subscriber'] = fields.ForeignKeyField(
|
||||||
|
'models.Subscriber', related_name='subscriptions', on_delete=fields.CASCADE, index=True
|
||||||
|
)
|
||||||
|
target_channel: fields.ForeignKeyRelation['TargetChannel'] = fields.ForeignKeyField(
|
||||||
|
'models.TargetChannel', related_name='subscriptions', on_delete=fields.CASCADE, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
placement_id: UUID
|
||||||
|
subscriber_id: UUID
|
||||||
|
target_channel_id: UUID
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = 'subscription'
|
table = 'subscription'
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import enum
|
import enum
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
from tortoise.models import Model
|
|
||||||
|
from .base import TimestampedModel
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .external_channel import ExternalChannel
|
||||||
|
from .user import User
|
||||||
|
|
||||||
|
|
||||||
class TargetChannelStatus(str, enum.Enum):
|
class TargetChannelStatus(str, enum.Enum):
|
||||||
@@ -10,30 +17,25 @@ class TargetChannelStatus(str, enum.Enum):
|
|||||||
ARCHIVED = 'archived'
|
ARCHIVED = 'archived'
|
||||||
|
|
||||||
|
|
||||||
class TargetChannel(Model):
|
class TargetChannel(TimestampedModel):
|
||||||
id = fields.UUIDField(pk=True)
|
id = fields.UUIDField(pk=True)
|
||||||
telegram_id = fields.BigIntField(unique=True, index=True)
|
telegram_id = fields.BigIntField(unique=True, index=True)
|
||||||
title = fields.CharField(max_length=255)
|
title = fields.CharField(max_length=255)
|
||||||
username = fields.CharField(max_length=255, null=True, index=True)
|
username = fields.CharField(max_length=255, null=True, index=True)
|
||||||
status = fields.CharEnumField(TargetChannelStatus, default=TargetChannelStatus.ACTIVE)
|
status = fields.CharEnumField(TargetChannelStatus, default=TargetChannelStatus.ACTIVE)
|
||||||
|
|
||||||
user = fields.ForeignKeyField('models.User', related_name='target_channels', on_delete=fields.CASCADE, index=True)
|
user: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField(
|
||||||
|
'models.User', related_name='target_channels', 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)
|
|
||||||
|
|
||||||
# Many-to-many с ExternalChannel через таблицу target_external_channel
|
|
||||||
external_channels: fields.ManyToManyRelation['ExternalChannel'] = fields.ManyToManyField(
|
external_channels: fields.ManyToManyRelation['ExternalChannel'] = fields.ManyToManyField(
|
||||||
'models.ExternalChannel',
|
'models.ExternalChannel',
|
||||||
related_name='target_channels',
|
related_name='target_channels',
|
||||||
through='target_external_channel',
|
through='target_external_channel',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reverse relations:
|
if TYPE_CHECKING:
|
||||||
# creatives: fields.ReverseRelation['Creative']
|
user_id: UUID
|
||||||
# placements: fields.ReverseRelation['Placement']
|
|
||||||
# subscriptions: fields.ReverseRelation['Subscription']
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = 'target_channel'
|
table = 'target_channel'
|
||||||
|
|||||||
@@ -1,28 +1,21 @@
|
|||||||
import enum
|
import enum
|
||||||
|
|
||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
from tortoise.models import Model
|
|
||||||
|
from .base import TimestampedModel
|
||||||
|
|
||||||
|
|
||||||
class TelegramStateEnum(str, enum.Enum):
|
class TelegramStateEnum(str, enum.Enum):
|
||||||
"""Telegram bot conversation states."""
|
|
||||||
|
|
||||||
CREATIVE_WAITING_CHANNEL = 'creative_waiting_channel'
|
CREATIVE_WAITING_CHANNEL = 'creative_waiting_channel'
|
||||||
CREATIVE_WAITING_NAME = 'creative_waiting_name'
|
CREATIVE_WAITING_NAME = 'creative_waiting_name'
|
||||||
CREATIVE_WAITING_TEXT = 'creative_waiting_text'
|
CREATIVE_WAITING_TEXT = 'creative_waiting_text'
|
||||||
|
|
||||||
|
|
||||||
class TelegramState(Model):
|
class TelegramState(TimestampedModel):
|
||||||
"""Хранит текущее состояние разговора пользователя с ботом для multi-step flows."""
|
|
||||||
|
|
||||||
id = fields.UUIDField(pk=True)
|
id = fields.UUIDField(pk=True)
|
||||||
telegram_id = fields.BigIntField(unique=True, index=True)
|
telegram_id = fields.BigIntField(unique=True, index=True)
|
||||||
state = fields.CharEnumField(TelegramStateEnum)
|
state = fields.CharEnumField(TelegramStateEnum)
|
||||||
context = fields.JSONField(default=dict)
|
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:
|
class Meta:
|
||||||
table = 'telegram_state'
|
table = 'telegram_state'
|
||||||
|
|||||||
@@ -1,22 +1,12 @@
|
|||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
from tortoise.models import Model
|
|
||||||
|
from .base import TimestampedModel
|
||||||
|
|
||||||
|
|
||||||
class User(Model):
|
class User(TimestampedModel):
|
||||||
id = fields.UUIDField(pk=True)
|
id = fields.UUIDField(pk=True)
|
||||||
telegram_id = fields.BigIntField(unique=True, index=True)
|
telegram_id = fields.BigIntField(unique=True, index=True)
|
||||||
username = fields.CharField(max_length=255, null=True)
|
username = fields.CharField(max_length=255, null=True)
|
||||||
|
|
||||||
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:
|
class Meta:
|
||||||
table = 'user'
|
table = 'user'
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import enum
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from src import domain
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramStateEnum(enum.StrEnum):
|
|
||||||
CREATIVE_WAITING_CHANNEL = 'creative_waiting_channel'
|
|
||||||
CREATIVE_WAITING_NAME = 'creative_waiting_name'
|
|
||||||
CREATIVE_WAITING_TEXT = 'creative_waiting_text'
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramState(domain.Base):
|
|
||||||
telegram_id: Mapped[int] = mapped_column(unique=True, index=True)
|
|
||||||
state: Mapped[TelegramStateEnum]
|
|
||||||
context: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
|
||||||
14
uv.lock
generated
14
uv.lock
generated
@@ -982,6 +982,7 @@ dependencies = [
|
|||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||||
{ name = "tortoise-orm" },
|
{ name = "tortoise-orm" },
|
||||||
|
{ name = "tortoise-orm-stubs" },
|
||||||
{ name = "uvicorn" },
|
{ name = "uvicorn" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1010,6 +1011,7 @@ requires-dist = [
|
|||||||
{ 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 = "tortoise-orm", specifier = ">=0.25.1" },
|
||||||
|
{ name = "tortoise-orm-stubs", specifier = ">=1.0.2" },
|
||||||
{ name = "uvicorn", specifier = ">=0.38.0" },
|
{ name = "uvicorn", specifier = ">=0.38.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1039,6 +1041,18 @@ 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" },
|
{ 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]]
|
||||||
|
name = "tortoise-orm-stubs"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "tortoise-orm" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ba/49/45b06cda907e55226b8ed4ddc71d13ff61505bfe366d72276462eeee9d2b/tortoise_orm_stubs-1.0.2.tar.gz", hash = "sha256:f4d6a810f295bebd83aa71b05ebd2decd883517f3c9530bd2376b9209b0777c6", size = 4559, upload-time = "2023-11-20T14:48:26.806Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/b1/f0b111dcf9381987f8acb143dd95b77934a3e9120a6c63b2cf4255c2934c/tortoise_orm_stubs-1.0.2-py3-none-any.whl", hash = "sha256:5ae3c2b0eb0286669563634b98202bbdf46349966b1c85659f3160de4fb655d6", size = 4681, upload-time = "2023-11-20T14:48:22.536Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ty"
|
name = "ty"
|
||||||
version = "0.0.1a25"
|
version = "0.0.1a25"
|
||||||
|
|||||||
Reference in New Issue
Block a user