Skip to content

Commit eaa5f20

Browse files
author
Адель
committed
feat: add campaign strategy endpoint
1 parent deeddf7 commit eaa5f20

10 files changed

Lines changed: 2213 additions & 1 deletion

File tree

app/main.py

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@
8484
YandexVCardRequest,
8585
YandexVCardResult,
8686
YandexVCardAssetItem,
87+
YandexStrategyReadResult,
88+
YandexStrategyRequest,
89+
YandexStrategyResult,
8790
LiveAdCreateRequest,
8891
LiveAdCreateResult,
8992
AdsModerateRequest,
@@ -2744,6 +2747,238 @@ def yandex_time_targeting(
27442747
) from exc
27452748

27462749

2750+
# ---------------------------------------------------------------------------
2751+
# Campaign strategy read (GET)
2752+
# ---------------------------------------------------------------------------
2753+
2754+
2755+
def _build_strategy_summary(
2756+
strategy: dict | None,
2757+
) -> dict | None:
2758+
"""Build a human-readable strategy summary from a raw BiddingStrategy block."""
2759+
import re
2760+
2761+
if not isinstance(strategy, dict):
2762+
return None
2763+
summary: dict[str, Any] = {}
2764+
2765+
def _to_snake(name: str) -> str:
2766+
# Convert CamelCase to snake_case: GoalId → goal_id
2767+
s1 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
2768+
s2 = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", s1)
2769+
return s2.lower()
2770+
2771+
search = strategy.get("Search")
2772+
if isinstance(search, dict):
2773+
search_summary: dict[str, Any] = {
2774+
"type": search.get("BiddingStrategyType", "UNKNOWN"),
2775+
}
2776+
for sub_key, sub_val in search.items():
2777+
if isinstance(sub_val, dict):
2778+
params: dict[str, Any] = {}
2779+
for pk, pv in sub_val.items():
2780+
snake_key = _to_snake(pk)
2781+
if pk in ("WeeklySpendLimit", "BidCeiling"):
2782+
try:
2783+
params[f"{snake_key}_rub"] = float(pv) / 1_000_000
2784+
except (TypeError, ValueError):
2785+
params[snake_key] = pv
2786+
else:
2787+
params[snake_key] = pv
2788+
search_summary[sub_key] = params
2789+
summary["search"] = search_summary
2790+
2791+
network = strategy.get("Network")
2792+
if isinstance(network, dict):
2793+
summary["network"] = {
2794+
"type": network.get("BiddingStrategyType", "UNKNOWN"),
2795+
}
2796+
2797+
return summary if summary else None
2798+
2799+
2800+
@app.get(
2801+
"/yandex/campaigns/{campaign_id}/strategy",
2802+
response_model=YandexStrategyReadResult,
2803+
)
2804+
def yandex_strategy_read(
2805+
campaign_id: str,
2806+
settings: Settings = Depends(get_settings),
2807+
client: YandexDirectClient | None = Depends(get_yandex_client),
2808+
) -> YandexStrategyReadResult:
2809+
"""Read the current bidding strategy of a campaign.
2810+
2811+
Pure read-only — no write gate. Available in all modes.
2812+
"""
2813+
if _is_live_read_mode(settings) and client is not None:
2814+
try:
2815+
response = client.campaigns_get_full_strategy(campaign_id)
2816+
except YandexDirectError as exc:
2817+
raise _yandex_error_to_502(exc) from exc
2818+
if not response.get("ok"):
2819+
err = response.get("error") or {}
2820+
raise HTTPException(
2821+
status_code=502,
2822+
detail={
2823+
"error_type": "YandexDirectError",
2824+
"message": (
2825+
"Yandex Direct rejected campaigns.get (strategy): "
2826+
f"error_code={err.get('error_code')!r}"
2827+
),
2828+
},
2829+
)
2830+
result = response.get("result") or {}
2831+
campaigns = result.get("Campaigns") if isinstance(result, dict) else None
2832+
if isinstance(campaigns, list) and campaigns and isinstance(campaigns[0], dict):
2833+
camp = campaigns[0]
2834+
campaign_name = camp.get("Name")
2835+
campaign_type = camp.get("Type")
2836+
state = camp.get("State")
2837+
status = camp.get("Status")
2838+
raw_daily_budget = camp.get("DailyBudget")
2839+
raw_counter_ids = camp.get("CounterIds")
2840+
tc = camp.get("TextCampaign")
2841+
raw_strategy = tc.get("BiddingStrategy") if isinstance(tc, dict) else None
2842+
else:
2843+
campaign_name = None
2844+
campaign_type = None
2845+
state = None
2846+
status = None
2847+
raw_daily_budget = None
2848+
raw_counter_ids = None
2849+
raw_strategy = None
2850+
2851+
daily_budget = (
2852+
dict(raw_daily_budget)
2853+
if isinstance(raw_daily_budget, dict)
2854+
else raw_daily_budget
2855+
)
2856+
counter_ids = (
2857+
list(raw_counter_ids)
2858+
if isinstance(raw_counter_ids, list)
2859+
else None
2860+
)
2861+
strategy = (
2862+
dict(raw_strategy) if isinstance(raw_strategy, dict) else None
2863+
)
2864+
strategy_summary = _build_strategy_summary(strategy)
2865+
2866+
return YandexStrategyReadResult(
2867+
campaign_id=campaign_id,
2868+
campaign_name=str(campaign_name) if campaign_name else None,
2869+
source="yandex",
2870+
read_only=True,
2871+
campaign_type=str(campaign_type) if campaign_type else None,
2872+
state=str(state) if state else None,
2873+
status=str(status) if status else None,
2874+
daily_budget=daily_budget,
2875+
counter_ids=counter_ids,
2876+
strategy=strategy,
2877+
strategy_summary=strategy_summary,
2878+
)
2879+
2880+
mock = mock_yandex.mock_strategy(campaign_id)
2881+
return YandexStrategyReadResult(
2882+
campaign_id=campaign_id,
2883+
campaign_name=mock.get("campaign_name"),
2884+
source="mock",
2885+
read_only=True,
2886+
campaign_type=mock.get("campaign_type"),
2887+
state=mock.get("state"),
2888+
status=mock.get("status"),
2889+
daily_budget=mock.get("daily_budget"),
2890+
counter_ids=mock.get("counter_ids"),
2891+
strategy=mock.get("strategy"),
2892+
strategy_summary=mock.get("strategy_summary"),
2893+
)
2894+
2895+
2896+
# ---------------------------------------------------------------------------
2897+
# Campaign strategy update (POST)
2898+
# ---------------------------------------------------------------------------
2899+
2900+
2901+
@app.post(
2902+
"/yandex/campaigns/{campaign_id}/strategy",
2903+
response_model=YandexStrategyResult,
2904+
)
2905+
def yandex_strategy_update(
2906+
campaign_id: str,
2907+
payload: YandexStrategyRequest,
2908+
settings: Settings = Depends(get_settings),
2909+
client: YandexDirectClient | None = Depends(get_yandex_client),
2910+
) -> YandexStrategyResult:
2911+
"""Update the bidding strategy of a campaign.
2912+
2913+
Currently supports WB_MAXIMUM_CONVERSION_RATE.
2914+
weekly_spend_limit and bid_ceiling are in RUBLES.
2915+
"""
2916+
if not payload.approved:
2917+
raise HTTPException(
2918+
status_code=409,
2919+
detail="Action requires explicit approval before strategy update",
2920+
)
2921+
if not payload.dry_run and settings.directpilot_mode != "live_write":
2922+
raise HTTPException(
2923+
status_code=409,
2924+
detail=(
2925+
f"Live writes require DIRECTPILOT_MODE=live_write; "
2926+
f"current mode is {settings.directpilot_mode!r}; "
2927+
f"strategy apply is not allowed in this mode "
2928+
f"(dry_run=True is the only allowed path)"
2929+
),
2930+
)
2931+
try:
2932+
return store.yandex_strategy_update(
2933+
campaign_id, payload, settings=settings, client=client
2934+
)
2935+
except ValueError as exc:
2936+
raise HTTPException(status_code=409, detail=str(exc)) from exc
2937+
except YandexDirectError as exc:
2938+
diagnostics = exc.diagnostics or {}
2939+
detail: dict[str, Any] = {
2940+
"error_type": "YandexDirectError",
2941+
"message": str(exc),
2942+
}
2943+
if "error_code" in diagnostics:
2944+
detail["error_code"] = diagnostics["error_code"]
2945+
if "error_detail" in diagnostics:
2946+
detail["error_detail"] = diagnostics["error_detail"]
2947+
if "payload_preview" in diagnostics:
2948+
detail["payload_preview"] = diagnostics["payload_preview"]
2949+
raise HTTPException(status_code=502, detail=detail) from exc
2950+
except Exception as exc:
2951+
try:
2952+
store.append_audit(
2953+
"yandex_strategy_failed",
2954+
campaign_id,
2955+
dry_run=False,
2956+
details={
2957+
"campaign_id": campaign_id,
2958+
"approved": payload.approved,
2959+
"idempotency_key": payload.idempotency_key,
2960+
"endpoint_safety_net": True,
2961+
"yandex_error": (
2962+
f"unexpected error in strategy endpoint: "
2963+
f"{type(exc).__name__}: {exc}"
2964+
),
2965+
"exception_type": type(exc).__name__,
2966+
},
2967+
)
2968+
except Exception:
2969+
pass
2970+
raise HTTPException(
2971+
status_code=502,
2972+
detail={
2973+
"error_type": "YandexDirectError",
2974+
"message": (
2975+
f"unexpected error during strategy update: "
2976+
f"{type(exc).__name__}"
2977+
),
2978+
},
2979+
) from exc
2980+
2981+
27472982
# ---------------------------------------------------------------------------
27482983
# Yandex Metrika — read-only (counters, goals, summary, traffic-sources)
27492984
#

0 commit comments

Comments
 (0)