feat: доменный нейминг изменен
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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')
|
||||
19
src/domain/placement_views_history.py
Normal file
19
src/domain/placement_views_history.py
Normal 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
21
src/domain/subscriber.py
Normal 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')
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user