feat: creatives and external channels

This commit is contained in:
Artem Tsyrulnikov
2025-11-10 15:13:38 +03:00
parent 3800c72662
commit cd167fdb43
73 changed files with 3024 additions and 947 deletions

View File

@@ -1,5 +1,41 @@
__all__ = ('Base', 'User', 'UserNotFound')
__all__ = (
'Base',
'User',
'TargetChannel',
'ExternalChannel',
'Creative',
'TargetChannelStatus',
'CreativeStatus',
'LoginToken',
'UserNotFound',
'LoginTokenNotFound',
'LoginTokenExpired',
'LoginTokenAlreadyUsed',
'TargetChannelNotFound',
'ChannelAlreadyExists',
'ChannelNoAdminRights',
'ExternalChannelNotFound',
'ExternalChannelAlreadyExists',
'CreativeNotFound',
'CreativeInUse',
)
from .base import Base
from .error import UserNotFound
from .creative import Creative, CreativeStatus
from .error import (
ChannelAlreadyExists,
ChannelNoAdminRights,
CreativeInUse,
CreativeNotFound,
ExternalChannelAlreadyExists,
ExternalChannelNotFound,
LoginTokenAlreadyUsed,
LoginTokenExpired,
LoginTokenNotFound,
TargetChannelNotFound,
UserNotFound,
)
from .external_channel import ExternalChannel
from .login_token import LoginToken
from .target_channel import TargetChannel, TargetChannelStatus
from .user import User

View File

@@ -3,11 +3,17 @@ import uuid
from sqlalchemy import func
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
from sqlalchemy.types import DateTime
class Base(DeclarativeBase):
__abstract__ = True
# Use timezone-aware datetime (Postgres TIMESTAMP WITH TIME ZONE)
type_annotation_map = {
datetime.datetime: DateTime(timezone=True),
}
# Авто имя таблиц по названию класса
@declared_attr.directive
def __tablename__(cls) -> str:
@@ -15,7 +21,5 @@ class Base(DeclarativeBase):
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
created_at: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
updated_at: Mapped[datetime.datetime] = mapped_column(
server_default=func.now(), onupdate=func.now()
)
updated_at: Mapped[datetime.datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
deleted_at: Mapped[datetime.datetime | None]

27
src/domain/creative.py Normal file
View File

@@ -0,0 +1,27 @@
import uuid
from enum import StrEnum
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src import domain
if TYPE_CHECKING:
from .target_channel import TargetChannel
class CreativeStatus(StrEnum):
ACTIVE = 'active'
ARCHIVED = 'archived'
class Creative(domain.Base):
name: Mapped[str]
text: Mapped[str]
status: Mapped[CreativeStatus]
purchases_count: Mapped[int] = mapped_column(default=0, server_default='0')
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id'), index=True)
target_channel_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('targetchannels.id'), index=True)
target_channel: Mapped['TargetChannel'] = relationship(back_populates='creatives')

View File

@@ -3,5 +3,58 @@ import uuid
from fastapi import HTTPException, status
def UserNotFound(user_id: uuid.UUID) -> HTTPException:
def UserNotFound(user_id: uuid.UUID | None = None) -> HTTPException:
if user_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'User {user_id} not found')
def LoginTokenNotFound() -> HTTPException:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Login token not found')
def LoginTokenExpired() -> HTTPException:
return HTTPException(status.HTTP_400_BAD_REQUEST, 'Login token has expired')
def LoginTokenAlreadyUsed() -> HTTPException:
return HTTPException(status.HTTP_400_BAD_REQUEST, 'Login token has already been used')
def TargetChannelNotFound(channel_id: uuid.UUID | None = None) -> HTTPException:
if channel_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Target channel not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Target channel {channel_id} not found')
def ChannelAlreadyExists(telegram_id: int) -> HTTPException:
return HTTPException(status.HTTP_409_CONFLICT, f'Channel {telegram_id} already exists in the system')
def ChannelNoAdminRights() -> HTTPException:
return HTTPException(
status.HTTP_403_FORBIDDEN, 'Bot must be added as administrator with invite link creation rights'
)
def ExternalChannelNotFound(channel_id: uuid.UUID | None = None) -> HTTPException:
if channel_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'External channel not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'External channel {channel_id} not found')
def ExternalChannelAlreadyExists(telegram_id: int) -> HTTPException:
return HTTPException(status.HTTP_409_CONFLICT, f'External channel {telegram_id} already exists')
def CreativeNotFound(creative_id: uuid.UUID | None = None) -> HTTPException:
if creative_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Creative not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Creative {creative_id} not found')
def CreativeInUse(creative_id: uuid.UUID) -> HTTPException:
return HTTPException(
status.HTTP_400_BAD_REQUEST,
f'Creative {creative_id} is used in active purchases and cannot be deleted',
)

View File

@@ -0,0 +1,33 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import BigInteger, Column, ForeignKey, Table
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src import domain
if TYPE_CHECKING:
from . import TargetChannel
# M2M association table between TargetChannel and ExternalChannel
target_channel_external_channel = Table(
'target_channel_external_channels',
domain.Base.metadata,
Column('target_channel_id', ForeignKey('targetchannels.id', ondelete='CASCADE'), primary_key=True),
Column('external_channel_id', ForeignKey('externalchannels.id', ondelete='CASCADE'), primary_key=True),
)
class ExternalChannel(domain.Base):
telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
title: Mapped[str]
username: Mapped[str | None] = mapped_column(index=True)
description: Mapped[str | None]
subscribers_count: Mapped[int | None]
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id'), index=True)
# M2M relationship with target channels
target_channels: Mapped[list['TargetChannel']] = relationship(
secondary=target_channel_external_channel,
back_populates='external_channels',
)

14
src/domain/login_token.py Normal file
View File

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

View File

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

View File

@@ -1,7 +1,16 @@
from sqlalchemy.orm import Mapped
from typing import TYPE_CHECKING
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src import domain
if TYPE_CHECKING:
from . import TargetChannel
class User(domain.Base):
name: Mapped[str]
telegram_id: Mapped[int] = mapped_column(unique=True, index=True)
username: Mapped[str | None]
# Relationship with channels
target_channels: Mapped[list['TargetChannel']] = relationship(back_populates='user')