Add ad campaign management features and enhance user attribution
- Introduced ad campaign management functionality, including the ability to create campaigns and attribute users based on ad parameters. - Updated user command handlers to process new ad-related parameters and log user interactions with ad campaigns. - Enhanced admin interfaces with new keyboard options for managing ads and displaying campaign information. - Added database models for ad campaigns and attributions, improving data management for advertising features. - Updated localization files to support new ad-related strings in both English and Russian.
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
from . import user_dal
|
||||
from . import payment_dal
|
||||
from . import subscription_dal
|
||||
from . import promo_code_dal
|
||||
from . import panel_sync_dal
|
||||
from . import message_log_dal
|
||||
from . import user_billing_dal
|
||||
from . import ad_dal
|
||||
|
||||
__all__ = (
|
||||
"user_dal",
|
||||
"payment_dal",
|
||||
"subscription_dal",
|
||||
"promo_code_dal",
|
||||
"panel_sync_dal",
|
||||
"message_log_dal",
|
||||
"user_billing_dal",
|
||||
"ad_dal",
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy import update, delete, func, and_
|
||||
|
||||
from ..models import AdCampaign, AdAttribution, Payment
|
||||
|
||||
|
||||
async def create_campaign(
|
||||
session: AsyncSession, *, source: str, start_param: str, cost: float
|
||||
) -> AdCampaign:
|
||||
existing = await get_campaign_by_start_param(session, start_param)
|
||||
if existing:
|
||||
raise ValueError("ad_campaign_start_param_exists")
|
||||
|
||||
campaign = AdCampaign(source=source, start_param=start_param, cost=float(cost))
|
||||
session.add(campaign)
|
||||
await session.flush()
|
||||
await session.refresh(campaign)
|
||||
logging.info(
|
||||
f"AdCampaign created id={campaign.ad_campaign_id}, source={source}, start={start_param}, cost={cost}"
|
||||
)
|
||||
return campaign
|
||||
|
||||
|
||||
async def get_campaign_by_id(session: AsyncSession, campaign_id: int) -> Optional[AdCampaign]:
|
||||
stmt = select(AdCampaign).where(AdCampaign.ad_campaign_id == campaign_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_campaign_by_start_param(session: AsyncSession, start_param: str) -> Optional[AdCampaign]:
|
||||
clean = start_param.strip()
|
||||
stmt = select(AdCampaign).where(AdCampaign.start_param == clean)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_campaigns(session: AsyncSession, *, only_active: bool = False) -> List[AdCampaign]:
|
||||
stmt = select(AdCampaign).order_by(AdCampaign.created_at.desc())
|
||||
if only_active:
|
||||
stmt = stmt.where(AdCampaign.is_active == True)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def toggle_campaign_active(session: AsyncSession, campaign_id: int, is_active: bool) -> bool:
|
||||
stmt = (
|
||||
update(AdCampaign)
|
||||
.where(AdCampaign.ad_campaign_id == campaign_id)
|
||||
.values(is_active=is_active)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
async def ensure_attribution(session: AsyncSession, *, user_id: int, campaign_id: int) -> AdAttribution:
|
||||
existing = await get_attribution_for_user(session, user_id)
|
||||
if existing:
|
||||
return existing
|
||||
attrib = AdAttribution(user_id=user_id, ad_campaign_id=campaign_id)
|
||||
session.add(attrib)
|
||||
await session.flush()
|
||||
await session.refresh(attrib)
|
||||
logging.info(f"AdAttribution created for user {user_id} -> campaign {campaign_id}")
|
||||
return attrib
|
||||
|
||||
|
||||
async def get_attribution_for_user(session: AsyncSession, user_id: int) -> Optional[AdAttribution]:
|
||||
stmt = select(AdAttribution).where(AdAttribution.user_id == user_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def mark_trial_activated(session: AsyncSession, user_id: int) -> bool:
|
||||
stmt = (
|
||||
update(AdAttribution)
|
||||
.where(and_(AdAttribution.user_id == user_id, AdAttribution.trial_activated_at.is_(None)))
|
||||
.values(trial_activated_at=func.now())
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
async def get_campaign_stats(session: AsyncSession, campaign_id: int) -> Dict[str, Any]:
|
||||
# Starts (attributed users)
|
||||
starts_stmt = select(func.count(AdAttribution.user_id)).where(
|
||||
AdAttribution.ad_campaign_id == campaign_id
|
||||
)
|
||||
starts = (await session.execute(starts_stmt)).scalar() or 0
|
||||
|
||||
# Trials
|
||||
trials_stmt = select(func.count(AdAttribution.user_id)).where(
|
||||
and_(AdAttribution.ad_campaign_id == campaign_id, AdAttribution.trial_activated_at.is_not(None))
|
||||
)
|
||||
trials = (await session.execute(trials_stmt)).scalar() or 0
|
||||
|
||||
# Payers (unique users with succeeded payments)
|
||||
payers_stmt = select(func.count(func.distinct(Payment.user_id))).select_from(Payment).where(
|
||||
and_(
|
||||
Payment.status == "succeeded",
|
||||
Payment.user_id.in_(
|
||||
select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id)
|
||||
),
|
||||
)
|
||||
)
|
||||
payers = (await session.execute(payers_stmt)).scalar() or 0
|
||||
|
||||
# Revenue sum
|
||||
revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where(
|
||||
and_(
|
||||
Payment.status == "succeeded",
|
||||
Payment.user_id.in_(
|
||||
select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id)
|
||||
),
|
||||
)
|
||||
)
|
||||
revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
|
||||
|
||||
return {
|
||||
"starts": int(starts),
|
||||
"trials": int(trials),
|
||||
"payers": int(payers),
|
||||
"revenue": revenue,
|
||||
}
|
||||
|
||||
|
||||
@@ -227,3 +227,35 @@ class PanelSyncStatus(Base):
|
||||
subscriptions_synced = Column(Integer, default=0)
|
||||
|
||||
__table_args__ = (UniqueConstraint('id'), )
|
||||
|
||||
|
||||
class AdCampaign(Base):
|
||||
__tablename__ = "ad_campaigns"
|
||||
|
||||
ad_campaign_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
source = Column(String, nullable=False, index=True)
|
||||
start_param = Column(String, nullable=False, unique=True, index=True)
|
||||
cost = Column(Float, nullable=False, default=0.0)
|
||||
is_active = Column(Boolean, default=True, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
attributions = relationship(
|
||||
"AdAttribution",
|
||||
back_populates="campaign",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AdCampaign(id={self.ad_campaign_id}, source='{self.source}', start_param='{self.start_param}', cost={self.cost})>"
|
||||
|
||||
|
||||
class AdAttribution(Base):
|
||||
__tablename__ = "ad_attributions"
|
||||
|
||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True, index=True)
|
||||
ad_campaign_id = Column(Integer, ForeignKey("ad_campaigns.ad_campaign_id"), nullable=False, index=True)
|
||||
first_start_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
trial_activated_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
campaign = relationship("AdCampaign", back_populates="attributions")
|
||||
|
||||
Reference in New Issue
Block a user