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()