Skip to content

Commit 15bbf5b

Browse files
author
Адель
committed
feat: expand Yandex Direct live-readonly API
- add live_readonly/live_write modes and guarded pause/resume controls - add real read endpoints for campaigns, ad groups, ads, keywords, reports, bids, dictionaries, changes, assets, audiences, and keyword research - add safety tests for approval, dry-run, idempotency, token redaction, and read-only mode - update Yandex Direct docs, OpenAPI, screenshots, and status documentation - keep TestClient on current path with httpx2 dev dependency to avoid deprecation warnings
1 parent 68dcc67 commit 15bbf5b

26 files changed

Lines changed: 7518 additions & 117 deletions

app/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class Settings(BaseSettings):
1616
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
1717

1818
app_env: str = "local"
19-
directpilot_mode: str = Field(default="mock", pattern="^(mock|sandbox|live_readonly)$")
19+
directpilot_mode: str = Field(default="mock", pattern="^(mock|sandbox|live_readonly|live_write)$")
2020
yandex_client_id: str | None = None
2121
yandex_client_secret: str | None = None
2222
yandex_oauth_token: str | None = None

app/main.py

Lines changed: 621 additions & 28 deletions
Large diffs are not rendered by default.

app/models.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Literal
1+
from typing import Any, Literal
22

33
from pydantic import BaseModel, Field, HttpUrl
44

@@ -366,6 +366,14 @@ class YandexSearchQueriesReport(BaseModel):
366366
read_only: bool = True
367367

368368

369+
class YandexRawResult(BaseModel):
370+
service: str
371+
method: str
372+
data: Any
373+
source: Literal["mock", "yandex"] = "yandex"
374+
read_only: bool = True
375+
376+
369377
# ---------------------------------------------------------------------------
370378
# Yandex Direct control facade (pause/resume)
371379
# ---------------------------------------------------------------------------

app/store.py

Lines changed: 139 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from __future__ import annotations
22

33
from itertools import count
4-
from typing import Iterable, Literal
4+
from typing import Any, Iterable, Literal
55

6+
from app.config import Settings
67
from app.models import (
78
Ad,
89
AdCreate,
@@ -27,6 +28,7 @@
2728
YandexControlRequest,
2829
YandexControlResult,
2930
)
31+
from app.yandex_direct import YandexDirectClient, YandexDirectError
3032

3133

3234
def _normalize_phrase(value: str) -> str:
@@ -584,38 +586,150 @@ def yandex_control(
584586
campaign_id: str,
585587
action: Literal["pause", "resume"],
586588
payload: YandexControlRequest,
589+
*,
590+
settings: Settings | None = None,
591+
client: YandexDirectClient | None = None,
587592
) -> YandexControlResult:
588593
if not payload.approved:
589594
raise ValueError("Action requires explicit approval")
590595
cache_key = f"{action}:{campaign_id}:{payload.idempotency_key}"
591596
if cache_key in self.yandex_actions_by_key:
592597
return self.yandex_actions_by_key[cache_key]
593598
new_status = "paused" if action == "pause" else "active"
594-
audit = self.append_audit(
595-
f"yandex_{action}_requested",
596-
campaign_id,
597-
dry_run=payload.dry_run,
598-
details={
599-
"approved": payload.approved,
600-
"idempotency_key": payload.idempotency_key,
601-
"reason": payload.reason,
602-
"new_status": new_status,
603-
},
604-
)
605-
result = YandexControlResult(
606-
campaign_id=campaign_id,
607-
action=action,
608-
dry_run=payload.dry_run,
609-
applied=not payload.dry_run,
610-
source="mock",
611-
audit_id=audit.id,
612-
new_status=new_status,
613-
)
614-
# mutate mock state only if not dry run
615-
if not payload.dry_run:
599+
600+
mode = settings.directpilot_mode if settings is not None else "mock"
601+
is_live = mode in ("sandbox", "live_readonly", "live_write")
602+
can_write = mode in ("sandbox", "live_write")
603+
604+
# ------------------------------------------------------------------
605+
# Dry-run paths: never perform a network write.
606+
# ------------------------------------------------------------------
607+
if not is_live:
608+
# Mock mode: pure in-memory mirror.
609+
audit = self.append_audit(
610+
f"yandex_{action}_requested",
611+
campaign_id,
612+
dry_run=payload.dry_run,
613+
details={
614+
"approved": payload.approved,
615+
"idempotency_key": payload.idempotency_key,
616+
"reason": payload.reason,
617+
"new_status": new_status,
618+
"source": "mock",
619+
},
620+
)
621+
result = YandexControlResult(
622+
campaign_id=campaign_id,
623+
action=action,
624+
dry_run=payload.dry_run,
625+
applied=not payload.dry_run,
626+
source="mock",
627+
audit_id=audit.id,
628+
new_status=new_status,
629+
)
630+
if not payload.dry_run:
631+
self.yandex_campaign_status[campaign_id] = new_status
632+
self.yandex_actions_by_key[cache_key] = result
633+
return result
634+
635+
# ------------------------------------------------------------------
636+
# Live modes (sandbox / live_readonly).
637+
# ------------------------------------------------------------------
638+
if payload.dry_run:
639+
# No network call; mark source=yandex, applied=False.
640+
audit = self.append_audit(
641+
f"yandex_{action}_requested",
642+
campaign_id,
643+
dry_run=True,
644+
details={
645+
"approved": payload.approved,
646+
"idempotency_key": payload.idempotency_key,
647+
"reason": payload.reason,
648+
"new_status": new_status,
649+
"source": "yandex",
650+
"mode": mode,
651+
},
652+
)
653+
result = YandexControlResult(
654+
campaign_id=campaign_id,
655+
action=action,
656+
dry_run=True,
657+
applied=False,
658+
source="yandex",
659+
audit_id=audit.id,
660+
new_status=new_status,
661+
)
662+
self.yandex_actions_by_key[cache_key] = result
663+
return result
664+
665+
if not can_write:
666+
raise YandexDirectError(
667+
"Live writes are not allowed in live_readonly mode; use live_write"
668+
)
669+
670+
# Real write path: caller must provide a client with a token.
671+
if client is None:
672+
raise YandexDirectError(
673+
"YandexDirectClient is required for live pause/resume writes"
674+
)
675+
676+
try:
677+
yandex_result: dict[str, Any]
678+
if action == "pause":
679+
yandex_result = client.suspend_campaign(campaign_id)
680+
else:
681+
yandex_result = client.resume_campaign(campaign_id)
682+
if not yandex_result.get("ok"):
683+
# Surface as a typed error so endpoints return 502.
684+
err = yandex_result.get("error") or {}
685+
raise YandexDirectError(
686+
f"Yandex Direct rejected {action}: error_code="
687+
f"{err.get('error_code')!r}"
688+
)
689+
# Success: mirror to local mock state for parity with mock mode.
616690
self.yandex_campaign_status[campaign_id] = new_status
617-
self.yandex_actions_by_key[cache_key] = result
618-
return result
691+
audit = self.append_audit(
692+
f"yandex_{action}_requested",
693+
campaign_id,
694+
dry_run=False,
695+
details={
696+
"approved": payload.approved,
697+
"idempotency_key": payload.idempotency_key,
698+
"reason": payload.reason,
699+
"new_status": new_status,
700+
"source": "yandex",
701+
"mode": mode,
702+
"yandex_result": yandex_result.get("result"),
703+
"yandex_units": yandex_result.get("units"),
704+
},
705+
)
706+
result = YandexControlResult(
707+
campaign_id=campaign_id,
708+
action=action,
709+
dry_run=False,
710+
applied=True,
711+
source="yandex",
712+
audit_id=audit.id,
713+
new_status=new_status,
714+
)
715+
self.yandex_actions_by_key[cache_key] = result
716+
return result
717+
except YandexDirectError as exc:
718+
# Record the failure for audit; never include the token.
719+
self.append_audit(
720+
f"yandex_{action}_failed",
721+
campaign_id,
722+
dry_run=False,
723+
details={
724+
"approved": payload.approved,
725+
"idempotency_key": payload.idempotency_key,
726+
"reason": payload.reason,
727+
"source": "yandex",
728+
"mode": mode,
729+
"yandex_error": str(exc),
730+
},
731+
)
732+
raise
619733

620734

621735
store = MockStore()

0 commit comments

Comments
 (0)