forked from sagnik/Project_Velocity
The complete code integration is done. Co-authored-by: Sagnik <sagnik7896@gmail.com> Reviewed-on: sagnik/Project_Velocity#18
521 lines
20 KiB
Python
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()
|