Skip to content

Commit 97e96a6

Browse files
author
Адель
committed
feat: support Direct autotargeting settings
1 parent c28313e commit 97e96a6

11 files changed

Lines changed: 4017 additions & 37 deletions

File tree

app/main.py

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@
9292
AdsModerateRequest,
9393
AdsModerateResult,
9494
ProviderWarning,
95+
# Autotargeting
96+
YandexAutotargetingReadResult,
97+
YandexAutotargetingRequest,
98+
YandexAutotargetingResult,
9599
)
96100
from app.store import store
97101
from app.yandex_direct import YandexDirectClient, YandexDirectError
@@ -2754,8 +2758,14 @@ def yandex_time_targeting(
27542758

27552759
def _build_strategy_summary(
27562760
strategy: dict | None,
2761+
priority_goals: dict | None = None,
27572762
) -> dict | None:
2758-
"""Build a human-readable strategy summary from a raw BiddingStrategy block."""
2763+
"""Build a human-readable strategy summary from a raw BiddingStrategy block.
2764+
2765+
``priority_goals`` is the optional raw ``TextCampaign.PriorityGoals``
2766+
dict (``{\"Items\": [{GoalId, Value}, ...]}`` as returned by v5 readback).
2767+
Values are converted from Direct micros to RUBLES.
2768+
"""
27592769
import re
27602770

27612771
if not isinstance(strategy, dict):
@@ -2794,6 +2804,21 @@ def _to_snake(name: str) -> str:
27942804
"type": network.get("BiddingStrategyType", "UNKNOWN"),
27952805
}
27962806

2807+
# Add PriorityGoals summary if present
2808+
if priority_goals is not None and isinstance(priority_goals, dict):
2809+
items = priority_goals.get("Items")
2810+
if isinstance(items, list) and items:
2811+
summary["priority_goals"] = []
2812+
for item in items:
2813+
if isinstance(item, dict) and "GoalId" in item:
2814+
pg: dict[str, Any] = {"goal_id": item["GoalId"]}
2815+
if "Value" in item:
2816+
try:
2817+
pg["value_rub"] = float(item["Value"]) / 1_000_000
2818+
except (TypeError, ValueError):
2819+
pg["value"] = item["Value"]
2820+
summary["priority_goals"].append(pg)
2821+
27972822
return summary if summary else None
27982823

27992824

@@ -2839,6 +2864,7 @@ def yandex_strategy_read(
28392864
tc = camp.get("TextCampaign")
28402865
raw_counter_ids = tc.get("CounterIds") if isinstance(tc, dict) else None
28412866
raw_strategy = tc.get("BiddingStrategy") if isinstance(tc, dict) else None
2867+
raw_priority_goals = tc.get("PriorityGoals") if isinstance(tc, dict) else None
28422868
else:
28432869
campaign_name = None
28442870
campaign_type = None
@@ -2847,6 +2873,7 @@ def yandex_strategy_read(
28472873
raw_daily_budget = None
28482874
raw_counter_ids = None
28492875
raw_strategy = None
2876+
raw_priority_goals = None
28502877

28512878
daily_budget = (
28522879
dict(raw_daily_budget)
@@ -2861,7 +2888,9 @@ def yandex_strategy_read(
28612888
strategy = (
28622889
dict(raw_strategy) if isinstance(raw_strategy, dict) else None
28632890
)
2864-
strategy_summary = _build_strategy_summary(strategy)
2891+
strategy_summary = _build_strategy_summary(
2892+
strategy, priority_goals=raw_priority_goals
2893+
)
28652894

28662895
return YandexStrategyReadResult(
28672896
campaign_id=campaign_id,
@@ -2873,6 +2902,7 @@ def yandex_strategy_read(
28732902
status=str(status) if status else None,
28742903
daily_budget=daily_budget,
28752904
counter_ids=counter_ids,
2905+
priority_goals=raw_priority_goals,
28762906
strategy=strategy,
28772907
strategy_summary=strategy_summary,
28782908
)
@@ -2888,6 +2918,7 @@ def yandex_strategy_read(
28882918
status=mock.get("status"),
28892919
daily_budget=mock.get("daily_budget"),
28902920
counter_ids=mock.get("counter_ids"),
2921+
priority_goals=mock.get("priority_goals"),
28912922
strategy=mock.get("strategy"),
28922923
strategy_summary=mock.get("strategy_summary"),
28932924
)
@@ -2979,6 +3010,129 @@ def yandex_strategy_update(
29793010
) from exc
29803011

29813012

3013+
# ---------------------------------------------------------------------------
3014+
# Autotargeting settings read (GET) / update (POST)
3015+
# ---------------------------------------------------------------------------
3016+
3017+
3018+
@app.get(
3019+
"/yandex/campaigns/{campaign_id}/autotargeting",
3020+
response_model=YandexAutotargetingReadResult,
3021+
)
3022+
def yandex_autotargeting_read(
3023+
campaign_id: str,
3024+
settings: Settings = Depends(get_settings),
3025+
client: YandexDirectClient | None = Depends(get_yandex_client),
3026+
) -> YandexAutotargetingReadResult:
3027+
"""Read autotargeting settings for all ad groups in a campaign.
3028+
3029+
Pure read-only — no write gate. Available in all modes.
3030+
Returns per-ad-group autotargeting categories and brand options
3031+
from the ``---autotargeting`` keyword rows.
3032+
"""
3033+
try:
3034+
return store.yandex_autotargeting_read(
3035+
campaign_id, settings=settings, client=client
3036+
)
3037+
except YandexDirectError as exc:
3038+
raise _yandex_error_to_502(exc) from exc
3039+
3040+
3041+
@app.post(
3042+
"/yandex/campaigns/{campaign_id}/autotargeting",
3043+
response_model=YandexAutotargetingResult,
3044+
)
3045+
def yandex_autotargeting_update(
3046+
campaign_id: str,
3047+
payload: YandexAutotargetingRequest,
3048+
settings: Settings = Depends(get_settings),
3049+
client: YandexDirectClient | None = Depends(get_yandex_client),
3050+
) -> YandexAutotargetingResult:
3051+
"""Update autotargeting settings for ad groups in a campaign.
3052+
3053+
Standard product gate contract:
3054+
``dry_run=True`` (default) is preview-only and never performs a network
3055+
write; the response includes the exact v5 ``keywords.update`` payload
3056+
that WOULD be sent, with ``applied=False``.
3057+
3058+
``dry_run=False`` requires ``DIRECTPILOT_MODE=live_write``,
3059+
``approved=True`` and a valid ``idempotency_key``.
3060+
3061+
Categories are always sent with all five booleans explicitly (``YES``
3062+
or ``NO``) to avoid the Direct API pitfall where missing categories
3063+
default to ``YES``.
3064+
3065+
Default preset for local service-search campaigns: ``exact_narrow``
3066+
(Exact=YES, Narrow=YES, Alternative=NO, Accessory=NO, Broader=NO).
3067+
Brand options default: WithoutBrands=YES, WithAdvertiserBrand=YES,
3068+
WithCompetitorsBrand=NO.
3069+
"""
3070+
if not payload.approved:
3071+
raise HTTPException(
3072+
status_code=409,
3073+
detail="Action requires explicit approval before autotargeting update",
3074+
)
3075+
if not payload.dry_run and settings.directpilot_mode != "live_write":
3076+
raise HTTPException(
3077+
status_code=409,
3078+
detail=(
3079+
f"Live writes require DIRECTPILOT_MODE=live_write; "
3080+
f"current mode is {settings.directpilot_mode!r}; "
3081+
f"autotargeting apply is not allowed in this mode "
3082+
f"(dry_run=True is the only allowed path)"
3083+
),
3084+
)
3085+
try:
3086+
return store.yandex_autotargeting_update(
3087+
campaign_id, payload, settings=settings, client=client
3088+
)
3089+
except ValueError as exc:
3090+
raise HTTPException(status_code=409, detail=str(exc)) from exc
3091+
except YandexDirectError as exc:
3092+
diagnostics = exc.diagnostics or {}
3093+
detail: dict[str, Any] = {
3094+
"error_type": "YandexDirectError",
3095+
"message": str(exc),
3096+
}
3097+
if "error_code" in diagnostics:
3098+
detail["error_code"] = diagnostics["error_code"]
3099+
if "error_detail" in diagnostics:
3100+
detail["error_detail"] = diagnostics["error_detail"]
3101+
if "payload_preview" in diagnostics:
3102+
detail["payload_preview"] = diagnostics["payload_preview"]
3103+
raise HTTPException(status_code=502, detail=detail) from exc
3104+
except Exception as exc:
3105+
try:
3106+
store.append_audit(
3107+
"yandex_autotargeting_failed",
3108+
campaign_id,
3109+
dry_run=False,
3110+
details={
3111+
"campaign_id": campaign_id,
3112+
"approved": payload.approved,
3113+
"idempotency_key": payload.idempotency_key,
3114+
"endpoint_safety_net": True,
3115+
"yandex_error": (
3116+
f"unexpected error in autotargeting endpoint: "
3117+
f"{type(exc).__name__}: {exc}"
3118+
),
3119+
"exception_type": type(exc).__name__,
3120+
},
3121+
)
3122+
except Exception:
3123+
pass
3124+
raise HTTPException(
3125+
status_code=502,
3126+
detail={
3127+
"error_type": "YandexDirectError",
3128+
"message": (
3129+
f"unexpected error during autotargeting update: "
3130+
f"{type(exc).__name__}"
3131+
),
3132+
},
3133+
) from exc
3134+
3135+
29823136
# ---------------------------------------------------------------------------
29833137
# Yandex Metrika — read-only (counters, goals, summary, traffic-sources)
29843138
#

0 commit comments

Comments
 (0)