|
84 | 84 | YandexVCardRequest, |
85 | 85 | YandexVCardResult, |
86 | 86 | YandexVCardAssetItem, |
| 87 | + YandexStrategyReadResult, |
| 88 | + YandexStrategyRequest, |
| 89 | + YandexStrategyResult, |
87 | 90 | LiveAdCreateRequest, |
88 | 91 | LiveAdCreateResult, |
89 | 92 | AdsModerateRequest, |
@@ -2744,6 +2747,238 @@ def yandex_time_targeting( |
2744 | 2747 | ) from exc |
2745 | 2748 |
|
2746 | 2749 |
|
| 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 | + |
2747 | 2982 | # --------------------------------------------------------------------------- |
2748 | 2983 | # Yandex Metrika — read-only (counters, goals, summary, traffic-sources) |
2749 | 2984 | # |
|
0 commit comments