|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | 3 | from itertools import count |
4 | | -from typing import Iterable, Literal |
| 4 | +from typing import Any, Iterable, Literal |
5 | 5 |
|
| 6 | +from app.config import Settings |
6 | 7 | from app.models import ( |
7 | 8 | Ad, |
8 | 9 | AdCreate, |
|
27 | 28 | YandexControlRequest, |
28 | 29 | YandexControlResult, |
29 | 30 | ) |
| 31 | +from app.yandex_direct import YandexDirectClient, YandexDirectError |
30 | 32 |
|
31 | 33 |
|
32 | 34 | def _normalize_phrase(value: str) -> str: |
@@ -584,38 +586,150 @@ def yandex_control( |
584 | 586 | campaign_id: str, |
585 | 587 | action: Literal["pause", "resume"], |
586 | 588 | payload: YandexControlRequest, |
| 589 | + *, |
| 590 | + settings: Settings | None = None, |
| 591 | + client: YandexDirectClient | None = None, |
587 | 592 | ) -> YandexControlResult: |
588 | 593 | if not payload.approved: |
589 | 594 | raise ValueError("Action requires explicit approval") |
590 | 595 | cache_key = f"{action}:{campaign_id}:{payload.idempotency_key}" |
591 | 596 | if cache_key in self.yandex_actions_by_key: |
592 | 597 | return self.yandex_actions_by_key[cache_key] |
593 | 598 | 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. |
616 | 690 | 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 |
619 | 733 |
|
620 | 734 |
|
621 | 735 | store = MockStore() |
0 commit comments