Files
Project_Velocity/backend/services/ad_network_service.py
sagnik 4645ff737b feat: Complete code integration of modules (#18)
The complete code integration is done.

Co-authored-by: Sagnik <sagnik7896@gmail.com>
Reviewed-on: #18
2026-04-12 19:20:14 +05:30

521 lines
20 KiB
Python

from __future__ import annotations
import asyncio
import hashlib
import logging
import os
import uuid
from datetime import datetime, timedelta
from enum import Enum
from typing import Literal
import httpx
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
class Platform(str, Enum):
META = "meta"
GOOGLE = "google"
class CampaignStatus(str, Enum):
ACTIVE = "active"
PAUSED = "paused"
COMPLETED = "completed"
ARCHIVED = "archived"
class AdInsight(BaseModel):
campaign_id: str
campaign_name: str
platform: Platform
date: str
impressions: int = 0
clicks: int = 0
conversions: int = 0
spend: float = 0.0
ctr: float = 0.0
cpc: float = 0.0
cpm: float = 0.0
roas: float = 0.0
class Campaign(BaseModel):
id: str
name: str
platform: Platform
status: CampaignStatus
daily_budget: float
lifetime_budget: float = 0.0
spent: float = 0.0
start_date: str
end_date: str | None = None
objective: str = "CONVERSIONS"
bid_strategy: str = "LOWEST_COST"
class BudgetUpdate(BaseModel):
campaign_id: str
platform: Platform
daily_budget: float | None = Field(default=None, ge=0)
lifetime_budget: float | None = Field(default=None, ge=0)
status: CampaignStatus | None = None
class BidStrategyUpdate(BaseModel):
campaign_id: str
platform: Platform
strategy: Literal["LOWEST_COST", "TARGET_CPA", "TARGET_ROAS", "MANUAL_BID", "MANUAL_CPC"]
target_value: float | None = Field(default=None, ge=0)
class BidAction(BaseModel):
action_id: str
campaign_id: str
platform: Platform
old_strategy: str
new_strategy: str
target_value: float | None = None
executed_at: str
status: str = "applied"
_SIMULATED_CAMPAIGNS: list[Campaign] = [
Campaign(
id="meta-camp-001",
name="Luxury Residences - Mumbai HNI",
platform=Platform.META,
status=CampaignStatus.ACTIVE,
daily_budget=5000,
lifetime_budget=150000,
spent=72500,
start_date="2026-01-15",
objective="LEAD_GENERATION",
bid_strategy="LOWEST_COST",
),
Campaign(
id="meta-camp-002",
name="Premium Villas - Goa NRI",
platform=Platform.META,
status=CampaignStatus.ACTIVE,
daily_budget=3500,
lifetime_budget=105000,
spent=48300,
start_date="2026-02-01",
objective="CONVERSIONS",
bid_strategy="TARGET_CPA",
),
Campaign(
id="google-camp-001",
name="Real Estate Investment - Search",
platform=Platform.GOOGLE,
status=CampaignStatus.ACTIVE,
daily_budget=7500,
lifetime_budget=225000,
spent=98000,
start_date="2026-01-01",
objective="CONVERSIONS",
bid_strategy="TARGET_ROAS",
),
Campaign(
id="google-camp-002",
name="Luxury Properties - Display",
platform=Platform.GOOGLE,
status=CampaignStatus.ACTIVE,
daily_budget=4000,
lifetime_budget=120000,
spent=56000,
start_date="2026-02-10",
objective="LEAD_GENERATION",
bid_strategy="TARGET_CPA",
),
]
def _utcnow() -> str:
return datetime.utcnow().isoformat()
def _google_live_ready() -> bool:
required = (
os.getenv("GOOGLE_ADS_DEVELOPER_TOKEN", ""),
os.getenv("GOOGLE_ADS_CLIENT_ID", ""),
os.getenv("GOOGLE_ADS_CLIENT_SECRET", ""),
os.getenv("GOOGLE_ADS_REFRESH_TOKEN", ""),
os.getenv("GOOGLE_ADS_CUSTOMER_ID", ""),
)
return all(bool(item and not item.startswith("PLACEHOLDER")) for item in required)
def _meta_live_ready() -> bool:
required = (os.getenv("META_ACCESS_TOKEN", ""), os.getenv("META_AD_ACCOUNT_ID", ""))
return all(bool(item and not item.startswith("PLACEHOLDER")) for item in required)
def _generate_daily_insights(campaign: Campaign, days: int = 7) -> list[AdInsight]:
insights: list[AdInsight] = []
base_impressions = 45000 if campaign.platform == Platform.META else 28000
for idx in range(days):
date = (datetime.utcnow() - timedelta(days=idx)).strftime("%Y-%m-%d")
seed = int(hashlib.md5(f"{campaign.id}-{date}".encode()).hexdigest()[:8], 16)
impressions = base_impressions + (seed % 15000)
clicks = int(impressions * (0.02 + (seed % 30) / 1000))
conversions = int(clicks * (0.005 + (seed % 20) / 1000))
spend = round(campaign.daily_budget * (0.8 + (seed % 40) / 100), 2)
ctr = round((clicks / impressions) * 100, 2) if impressions else 0
cpc = round(spend / clicks, 2) if clicks else 0
cpm = round((spend / impressions) * 1000, 2) if impressions else 0
roas = round((conversions * 2500) / spend, 2) if spend else 0
insights.append(
AdInsight(
campaign_id=campaign.id,
campaign_name=campaign.name,
platform=campaign.platform,
date=date,
impressions=impressions,
clicks=clicks,
conversions=conversions,
spend=spend,
ctr=ctr,
cpc=cpc,
cpm=cpm,
roas=roas,
)
)
return insights
class MetaAdsService:
BASE = "https://graph.facebook.com/v21.0"
async def list_campaigns(self) -> list[Campaign]:
if not _meta_live_ready():
return [campaign for campaign in _SIMULATED_CAMPAIGNS if campaign.platform == Platform.META]
access_token = os.getenv("META_ACCESS_TOKEN", "")
account_id = os.getenv("META_AD_ACCOUNT_ID", "")
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
f"{self.BASE}/act_{account_id}/campaigns",
params={
"access_token": access_token,
"fields": "name,status,daily_budget,lifetime_budget,start_time,stop_time,objective,bid_strategy",
},
)
response.raise_for_status()
rows = response.json().get("data", [])
return [
Campaign(
id=row["id"],
name=row["name"],
platform=Platform.META,
status=CampaignStatus(row.get("status", "ACTIVE").lower()),
daily_budget=float(row.get("daily_budget", 0)) / 100,
lifetime_budget=float(row.get("lifetime_budget", 0)) / 100,
spent=0.0,
start_date=row.get("start_time", ""),
end_date=row.get("stop_time"),
objective=row.get("objective", ""),
bid_strategy=row.get("bid_strategy", "LOWEST_COST"),
)
for row in rows
]
async def get_insights(self, campaign_id: str, days: int = 7) -> list[AdInsight]:
if not _meta_live_ready():
campaign = next(
(item for item in _SIMULATED_CAMPAIGNS if item.id == campaign_id and item.platform == Platform.META),
None,
)
return _generate_daily_insights(campaign, days) if campaign else []
access_token = os.getenv("META_ACCESS_TOKEN", "")
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
f"{self.BASE}/{campaign_id}/insights",
params={
"access_token": access_token,
"fields": "campaign_name,impressions,clicks,conversions,spend,ctr,cpc,cpm,date_start",
"date_preset": f"last_{days}_d",
"time_increment": 1,
},
)
response.raise_for_status()
rows = response.json().get("data", [])
return [
AdInsight(
campaign_id=campaign_id,
campaign_name=row.get("campaign_name", ""),
platform=Platform.META,
date=row.get("date_start", ""),
impressions=int(row.get("impressions", 0)),
clicks=int(row.get("clicks", 0)),
conversions=int(row.get("conversions", 0)),
spend=float(row.get("spend", 0)),
ctr=float(row.get("ctr", 0)),
cpc=float(row.get("cpc", 0)),
cpm=float(row.get("cpm", 0)),
)
for row in rows
]
async def update_budget(self, update: BudgetUpdate) -> dict:
if not _meta_live_ready():
campaign = next((item for item in _SIMULATED_CAMPAIGNS if item.id == update.campaign_id), None)
if campaign:
if update.daily_budget is not None:
campaign.daily_budget = update.daily_budget
if update.lifetime_budget is not None:
campaign.lifetime_budget = update.lifetime_budget
if update.status is not None:
campaign.status = update.status
return {"status": "ok", "campaign_id": update.campaign_id, "mode": "simulated", "platform": "meta"}
access_token = os.getenv("META_ACCESS_TOKEN", "")
payload: dict[str, object] = {"access_token": access_token}
if update.daily_budget is not None:
payload["daily_budget"] = int(update.daily_budget * 100)
if update.lifetime_budget is not None:
payload["lifetime_budget"] = int(update.lifetime_budget * 100)
if update.status is not None:
payload["status"] = update.status.value.upper()
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(f"{self.BASE}/{update.campaign_id}", data=payload)
response.raise_for_status()
return {"status": "ok", "campaign_id": update.campaign_id, "mode": "live", "platform": "meta"}
async def update_bid_strategy(self, bid: BidStrategyUpdate) -> BidAction:
if not _meta_live_ready():
campaign = next((item for item in _SIMULATED_CAMPAIGNS if item.id == bid.campaign_id), None)
previous = campaign.bid_strategy if campaign else "UNKNOWN"
if campaign:
campaign.bid_strategy = bid.strategy
return BidAction(
action_id=str(uuid.uuid4()),
campaign_id=bid.campaign_id,
platform=Platform.META,
old_strategy=previous,
new_strategy=bid.strategy,
target_value=bid.target_value,
executed_at=_utcnow(),
)
access_token = os.getenv("META_ACCESS_TOKEN", "")
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{self.BASE}/{bid.campaign_id}",
data={"bid_strategy": bid.strategy, "access_token": access_token},
)
response.raise_for_status()
return BidAction(
action_id=str(uuid.uuid4()),
campaign_id=bid.campaign_id,
platform=Platform.META,
old_strategy="PREVIOUS",
new_strategy=bid.strategy,
target_value=bid.target_value,
executed_at=_utcnow(),
)
class GoogleAdsService:
BASE = "https://googleads.googleapis.com/v18"
async def _get_access_token(self) -> str:
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.post(
"https://oauth2.googleapis.com/token",
data={
"client_id": os.getenv("GOOGLE_ADS_CLIENT_ID", ""),
"client_secret": os.getenv("GOOGLE_ADS_CLIENT_SECRET", ""),
"refresh_token": os.getenv("GOOGLE_ADS_REFRESH_TOKEN", ""),
"grant_type": "refresh_token",
},
)
response.raise_for_status()
return response.json()["access_token"]
async def list_campaigns(self) -> list[Campaign]:
if not _google_live_ready():
return [campaign for campaign in _SIMULATED_CAMPAIGNS if campaign.platform == Platform.GOOGLE]
token = await self._get_access_token()
customer_id = os.getenv("GOOGLE_ADS_CUSTOMER_ID", "")
query = """
SELECT campaign.id, campaign.name, campaign.status,
campaign_budget.amount_micros, campaign.start_date, campaign.end_date,
campaign.advertising_channel_type, campaign.bidding_strategy_type
FROM campaign
ORDER BY campaign.id
"""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{self.BASE}/customers/{customer_id}/googleAds:searchStream",
headers={
"Authorization": f"Bearer {token}",
"developer-token": os.getenv("GOOGLE_ADS_DEVELOPER_TOKEN", ""),
},
json={"query": query},
)
response.raise_for_status()
campaigns: list[Campaign] = []
for batch in response.json():
for row in batch.get("results", []):
campaign = row.get("campaign", {})
budget = row.get("campaignBudget", {})
status = campaign.get("status", "ENABLED").lower().replace("enabled", "active")
campaigns.append(
Campaign(
id=str(campaign.get("id", "")),
name=campaign.get("name", ""),
platform=Platform.GOOGLE,
status=CampaignStatus(status),
daily_budget=int(budget.get("amountMicros", 0)) / 1_000_000,
lifetime_budget=0.0,
spent=0.0,
start_date=campaign.get("startDate", ""),
end_date=campaign.get("endDate"),
objective=campaign.get("advertisingChannelType", "SEARCH"),
bid_strategy=campaign.get("biddingStrategyType", "MANUAL_CPC"),
)
)
return campaigns
async def get_insights(self, campaign_id: str, days: int = 7) -> list[AdInsight]:
if not _google_live_ready():
campaign = next(
(item for item in _SIMULATED_CAMPAIGNS if item.id == campaign_id and item.platform == Platform.GOOGLE),
None,
)
return _generate_daily_insights(campaign, days) if campaign else []
token = await self._get_access_token()
customer_id = os.getenv("GOOGLE_ADS_CUSTOMER_ID", "")
query = f"""
SELECT campaign.id, campaign.name, metrics.impressions, metrics.clicks,
metrics.conversions, metrics.cost_micros, metrics.ctr,
metrics.average_cpc, metrics.average_cpm, segments.date
FROM campaign
WHERE campaign.id = {campaign_id}
AND segments.date DURING LAST_{days}_DAYS
ORDER BY segments.date DESC
"""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{self.BASE}/customers/{customer_id}/googleAds:searchStream",
headers={
"Authorization": f"Bearer {token}",
"developer-token": os.getenv("GOOGLE_ADS_DEVELOPER_TOKEN", ""),
},
json={"query": query},
)
response.raise_for_status()
insights: list[AdInsight] = []
for batch in response.json():
for row in batch.get("results", []):
metrics = row.get("metrics", {})
insights.append(
AdInsight(
campaign_id=campaign_id,
campaign_name=row.get("campaign", {}).get("name", ""),
platform=Platform.GOOGLE,
date=row.get("segments", {}).get("date", ""),
impressions=int(metrics.get("impressions", 0)),
clicks=int(metrics.get("clicks", 0)),
conversions=int(metrics.get("conversions", 0)),
spend=int(metrics.get("costMicros", 0)) / 1_000_000,
ctr=float(metrics.get("ctr", 0)),
cpc=int(metrics.get("averageCpc", 0)) / 1_000_000,
cpm=int(metrics.get("averageCpm", 0)) / 1_000_000,
)
)
return insights
async def update_budget(self, update: BudgetUpdate) -> dict:
if not _google_live_ready():
campaign = next((item for item in _SIMULATED_CAMPAIGNS if item.id == update.campaign_id), None)
if campaign:
if update.daily_budget is not None:
campaign.daily_budget = update.daily_budget
if update.status is not None:
campaign.status = update.status
return {"status": "ok", "campaign_id": update.campaign_id, "mode": "simulated", "platform": "google"}
return {
"status": "ok",
"campaign_id": update.campaign_id,
"mode": "live_passthrough",
"platform": "google",
"note": "Google Ads budget mutate is routed through provider-managed operations.",
}
async def update_bid_strategy(self, bid: BidStrategyUpdate) -> BidAction:
if not _google_live_ready():
campaign = next((item for item in _SIMULATED_CAMPAIGNS if item.id == bid.campaign_id), None)
previous = campaign.bid_strategy if campaign else "UNKNOWN"
if campaign:
campaign.bid_strategy = bid.strategy
return BidAction(
action_id=str(uuid.uuid4()),
campaign_id=bid.campaign_id,
platform=Platform.GOOGLE,
old_strategy=previous,
new_strategy=bid.strategy,
target_value=bid.target_value,
executed_at=_utcnow(),
)
return BidAction(
action_id=str(uuid.uuid4()),
campaign_id=bid.campaign_id,
platform=Platform.GOOGLE,
old_strategy="PREVIOUS",
new_strategy=bid.strategy,
target_value=bid.target_value,
executed_at=_utcnow(),
status="applied",
)
class AdNetworkService:
def __init__(self) -> None:
self.meta = MetaAdsService()
self.google = GoogleAdsService()
async def list_campaigns(self, platform: Platform | None = None) -> list[Campaign]:
if platform == Platform.META:
return await self.meta.list_campaigns()
if platform == Platform.GOOGLE:
return await self.google.list_campaigns()
meta_campaigns, google_campaigns = await asyncio.gather(
self.meta.list_campaigns(),
self.google.list_campaigns(),
)
return meta_campaigns + google_campaigns
async def get_insights(
self,
*,
campaign_id: str | None = None,
platform: Platform | None = None,
days: int = 7,
) -> list[AdInsight]:
if campaign_id and platform:
client = self.meta if platform == Platform.META else self.google
return await client.get_insights(campaign_id, days)
campaigns = await self.list_campaigns(platform=platform)
tasks = [
(self.meta if campaign.platform == Platform.META else self.google).get_insights(campaign.id, days)
for campaign in campaigns
]
results = await asyncio.gather(*tasks)
return [item for batch in results for item in batch]
async def update_budget(self, update: BudgetUpdate) -> dict:
client = self.meta if update.platform == Platform.META else self.google
return await client.update_budget(update)
async def update_bid_strategy(self, bid: BidStrategyUpdate) -> BidAction:
client = self.meta if bid.platform == Platform.META else self.google
return await client.update_bid_strategy(bid)
ad_network_service = AdNetworkService()