feat: доменный нейминг изменен

This commit is contained in:
Artem Tsyrulnikov
2025-11-13 01:18:04 +03:00
parent a48d5e67cb
commit bba37fbb21
51 changed files with 1144 additions and 1154 deletions

View File

@@ -4,12 +4,13 @@ __all__ = (
'TargetChannel',
'ExternalChannel',
'Creative',
'Purchase',
'Placement',
'Subscription',
'ViewsSnapshot',
'Subscriber',
'PlacementViewsHistory',
'TargetChannelStatus',
'CreativeStatus',
'PurchaseStatus',
'PlacementStatus',
'InviteLinkType',
'PostViewsAvailability',
'LoginToken',
@@ -24,7 +25,7 @@ __all__ = (
'ExternalChannelAlreadyExists',
'CreativeNotFound',
'CreativeInUse',
'PurchaseNotFound',
'PlacementNotFound',
)
from .base import Base
@@ -39,14 +40,15 @@ from .error import (
LoginTokenAlreadyUsed,
LoginTokenExpired,
LoginTokenNotFound,
PurchaseNotFound,
PlacementNotFound,
TargetChannelNotFound,
UserNotFound,
)
from .external_channel import ExternalChannel
from .login_token import LoginToken
from .purchase import InviteLinkType, PostViewsAvailability, Purchase, PurchaseStatus
from .placement import InviteLinkType, Placement, PlacementStatus, PostViewsAvailability
from .placement_views_history import PlacementViewsHistory
from .subscriber import Subscriber
from .subscription import Subscription
from .target_channel import TargetChannel, TargetChannelStatus
from .user import User
from .views_snapshot import ViewsSnapshot

View File

@@ -1,4 +1,5 @@
import datetime
import re
import uuid
from sqlalchemy import func
@@ -6,6 +7,12 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
from sqlalchemy.types import DateTime
def _camel_to_snake(name: str) -> str:
"""Convert CamelCase to snake_case."""
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
@@ -14,10 +21,10 @@ class Base(DeclarativeBase):
datetime.datetime: DateTime(timezone=True),
}
# Авто имя таблиц по названию класса
# Авто имя таблиц по названию класса в snake_case
@declared_attr.directive
def __tablename__(cls) -> str:
return f'{cls.__name__.lower()}s'
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())

View File

@@ -20,8 +20,8 @@ class Creative(domain.Base):
name: Mapped[str]
text: Mapped[str]
status: Mapped[CreativeStatus]
purchases_count: Mapped[int] = mapped_column(default=0, server_default='0')
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id'), index=True)
placements_count: Mapped[int] = mapped_column(default=0, server_default='0')
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('user.id'), index=True)
target_channel_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('targetchannels.id'), index=True)
target_channel_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('target_channel.id'), index=True)
target_channel: Mapped['TargetChannel'] = relationship(back_populates='creatives')

View File

@@ -56,11 +56,11 @@ def CreativeNotFound(creative_id: uuid.UUID | None = None) -> HTTPException:
def CreativeInUse(creative_id: uuid.UUID) -> HTTPException:
return HTTPException(
status.HTTP_400_BAD_REQUEST,
f'Creative {creative_id} is used in active purchases and cannot be deleted',
f'Creative {creative_id} is used in active placements and cannot be deleted',
)
def PurchaseNotFound(purchase_id: uuid.UUID | None = None) -> HTTPException:
if purchase_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase {purchase_id} not found')
def PlacementNotFound(placement_id: uuid.UUID | None = None) -> HTTPException:
if placement_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Placement not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Placement {placement_id} not found')

View File

@@ -9,12 +9,11 @@ from src import domain
if TYPE_CHECKING:
from . import TargetChannel
# M2M association table between TargetChannel and ExternalChannel
target_channel_external_channel = Table(
'target_channel_external_channels',
target_external_channel = Table(
'target_external_channel',
domain.Base.metadata,
Column('target_channel_id', ForeignKey('targetchannels.id', ondelete='CASCADE'), primary_key=True),
Column('external_channel_id', ForeignKey('externalchannels.id', ondelete='CASCADE'), primary_key=True),
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),
)
@@ -24,10 +23,9 @@ class ExternalChannel(domain.Base):
username: Mapped[str | None] = mapped_column(index=True)
description: Mapped[str | None]
subscribers_count: Mapped[int | None]
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id'), index=True)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('user.id'), index=True)
# M2M relationship with target channels
target_channels: Mapped[list['TargetChannel']] = relationship(
secondary=target_channel_external_channel,
secondary=target_external_channel,
back_populates='external_channels',
)

View File

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

View File

@@ -11,10 +11,12 @@ 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 PurchaseStatus(StrEnum):
class PlacementStatus(StrEnum):
ACTIVE = 'active'
ARCHIVED = 'archived'
@@ -33,12 +35,12 @@ class PostViewsAvailability(StrEnum):
MANUAL = 'manual' # Просмотры вводятся вручную
class Purchase(domain.Base):
class Placement(domain.Base):
# Основные поля
target_channel_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('targetchannels.id'), index=True)
external_channel_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('externalchannels.id'), index=True)
creative_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('creatives.id'), index=True)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id'), index=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)
creative_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('creative.id'), index=True)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('user.id'), index=True)
# Данные закупа
placement_date: Mapped[datetime.datetime] # дата размещения (планируемая или фактическая)
@@ -49,7 +51,7 @@ class Purchase(domain.Base):
invite_link: Mapped[str] # уникальная пригласительная ссылка
# Статус
status: Mapped[PurchaseStatus] = mapped_column(default=PurchaseStatus.ACTIVE)
status: Mapped[PlacementStatus] = mapped_column(default=PlacementStatus.ACTIVE)
# Метрики (будут обновляться из Telegram API)
subscriptions_count: Mapped[int] = mapped_column(default=0, server_default='0')
@@ -63,3 +65,5 @@ class Purchase(domain.Base):
target_channel: Mapped['TargetChannel'] = relationship()
external_channel: Mapped['ExternalChannel'] = relationship()
creative: Mapped['Creative'] = relationship()
subscriptions: Mapped[list['Subscription']] = relationship(back_populates='placement')
views_histories: Mapped[list['PlacementViewsHistory']] = relationship(back_populates='placement')

View File

@@ -0,0 +1,19 @@
import datetime
import uuid
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):
placement_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('placement.id'), index=True)
views_count: Mapped[int] # Количество просмотров на момент снимка
fetched_at: Mapped[datetime.datetime] = mapped_column(index=True)
placement: Mapped['Placement'] = relationship(back_populates='views_histories')

21
src/domain/subscriber.py Normal file
View File

@@ -0,0 +1,21 @@
from typing import TYPE_CHECKING
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):
"""Пользователь Telegram, подписавшийся через invite links."""
telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
username: Mapped[str | None]
first_name: Mapped[str | None]
last_name: Mapped[str | None]
# Relationships
subscriptions: Mapped[list['Subscription']] = relationship(back_populates='subscriber')

View File

@@ -1,22 +1,21 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src import domain
if TYPE_CHECKING:
from .purchase import Purchase
from .placement import Placement
from .subscriber import Subscriber
class Subscription(domain.Base):
"""Подписка пользователя в целевой канал через конкретный закуп."""
purchase_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('purchases.id'), index=True)
user_telegram_id: Mapped[int] = mapped_column(BigInteger, index=True)
username: Mapped[str | None]
placement_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('placement.id'), index=True)
subscriber_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('subscriber.id'), index=True)
invite_link: Mapped[str] = mapped_column(index=True) # По которой пришёл пользователь
# Relationship
purchase: Mapped['Purchase'] = relationship()
# Relationships
placement: Mapped['Placement'] = relationship(back_populates='subscriptions')
subscriber: Mapped['Subscriber'] = relationship(back_populates='subscriptions')

View File

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

View File

@@ -1,22 +0,0 @@
import datetime
import uuid
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 .purchase import Purchase
class ViewsSnapshot(domain.Base):
"""Снимок просмотров рекламного поста в определенный момент времени."""
purchase_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('purchases.id'), index=True)
views_count: Mapped[int] # Количество просмотров на момент снимка
fetched_at: Mapped[datetime.datetime] = mapped_column(index=True) # Когда получены данные
# Relationship
purchase: Mapped['Purchase'] = relationship()