|
96 | 96 | YandexAutotargetingReadResult, |
97 | 97 | YandexAutotargetingRequest, |
98 | 98 | YandexAutotargetingResult, |
| 99 | + # UTM |
| 100 | + UtmAuditResult, |
| 101 | + UtmApplyRequest, |
| 102 | + UtmApplyResult, |
| 103 | + UtmConfig, |
| 104 | + UtmPlanRequest, |
| 105 | + UtmPlanResult, |
| 106 | + # Keyword bids |
| 107 | + KeywordBidItem, |
| 108 | + KeywordBidUpdateRequest, |
| 109 | + KeywordBidUpdateResult, |
99 | 110 | ) |
100 | 111 | from app.store import store |
101 | 112 | from app.yandex_direct import YandexDirectClient, YandexDirectError |
@@ -3133,6 +3144,212 @@ def yandex_autotargeting_update( |
3133 | 3144 | ) from exc |
3134 | 3145 |
|
3135 | 3146 |
|
| 3147 | +# --------------------------------------------------------------------------- |
| 3148 | +# Keyword bids update — live-safe SearchBid / ContextBid changes |
| 3149 | +# --------------------------------------------------------------------------- |
| 3150 | + |
| 3151 | + |
| 3152 | +@app.post( |
| 3153 | + "/yandex/campaigns/{campaign_id}/bids", |
| 3154 | + response_model=KeywordBidUpdateResult, |
| 3155 | + responses={ |
| 3156 | + 409: { |
| 3157 | + "description": "Safety gate or idempotency conflict: missing approval, non-live_write apply, or replay payload mismatch.", |
| 3158 | + }, |
| 3159 | + 502: { |
| 3160 | + "description": "Upstream Yandex Direct keywordbids.set / readback failure, with redacted diagnostics only.", |
| 3161 | + }, |
| 3162 | + }, |
| 3163 | +) |
| 3164 | +def yandex_keyword_bids_update( |
| 3165 | + campaign_id: str, |
| 3166 | + payload: KeywordBidUpdateRequest, |
| 3167 | + settings: Settings = Depends(get_settings), |
| 3168 | + client: YandexDirectClient | None = Depends(get_yandex_client), |
| 3169 | +) -> KeywordBidUpdateResult: |
| 3170 | + """Update SearchBid / ContextBid for existing keywords via v5 ``keywordbids.set``. |
| 3171 | +
|
| 3172 | + Standard product gate contract: |
| 3173 | + ``dry_run=True`` (default) is preview-only and never performs a network |
| 3174 | + write; the response includes the exact v5 ``keywordbids.set`` payload |
| 3175 | + that WOULD be sent, with ``applied=False``. |
| 3176 | +
|
| 3177 | + ``dry_run=False`` requires ``DIRECTPILOT_MODE=live_write``, |
| 3178 | + ``approved=True`` and a valid ``idempotency_key``. |
| 3179 | +
|
| 3180 | + Request items use RUBLES at the REST boundary; the store converts to |
| 3181 | + Direct micros (× 1 000 000). The minimal v5 item shape is |
| 3182 | + ``KeywordId + SearchBid`` / ``KeywordId + ContextBid`` — no |
| 3183 | + CampaignId / AdGroupId in the item. |
| 3184 | +
|
| 3185 | + After apply, the endpoint reads back keyword bids for the campaign |
| 3186 | + and returns the changed keyword ids with current Bid/ContextBid. |
| 3187 | + """ |
| 3188 | + if not payload.approved: |
| 3189 | + raise HTTPException( |
| 3190 | + status_code=409, |
| 3191 | + detail="Action requires explicit approval before keyword bids update", |
| 3192 | + ) |
| 3193 | + if not payload.dry_run and settings.directpilot_mode != "live_write": |
| 3194 | + raise HTTPException( |
| 3195 | + status_code=409, |
| 3196 | + detail=( |
| 3197 | + f"Live writes require DIRECTPILOT_MODE=live_write; " |
| 3198 | + f"current mode is {settings.directpilot_mode!r}; " |
| 3199 | + f"keyword bids apply is not allowed in this mode " |
| 3200 | + f"(dry_run=True is the only allowed path)" |
| 3201 | + ), |
| 3202 | + ) |
| 3203 | + try: |
| 3204 | + return store.yandex_keyword_bids_update( |
| 3205 | + campaign_id, payload, settings=settings, client=client |
| 3206 | + ) |
| 3207 | + except ValueError as exc: |
| 3208 | + raise HTTPException(status_code=409, detail=str(exc)) from exc |
| 3209 | + except YandexDirectError as exc: |
| 3210 | + diagnostics = exc.diagnostics or {} |
| 3211 | + detail: dict[str, Any] = { |
| 3212 | + "error_type": "YandexDirectError", |
| 3213 | + "message": str(exc), |
| 3214 | + } |
| 3215 | + if "error_code" in diagnostics: |
| 3216 | + detail["error_code"] = diagnostics["error_code"] |
| 3217 | + if "error_detail" in diagnostics: |
| 3218 | + detail["error_detail"] = diagnostics["error_detail"] |
| 3219 | + if "payload_preview" in diagnostics: |
| 3220 | + detail["payload_preview"] = diagnostics["payload_preview"] |
| 3221 | + raise HTTPException(status_code=502, detail=detail) from exc |
| 3222 | + except Exception as exc: |
| 3223 | + try: |
| 3224 | + store.append_audit( |
| 3225 | + "yandex_keyword_bids_failed", |
| 3226 | + campaign_id, |
| 3227 | + dry_run=False, |
| 3228 | + details={ |
| 3229 | + "campaign_id": campaign_id, |
| 3230 | + "approved": payload.approved, |
| 3231 | + "idempotency_key": payload.idempotency_key, |
| 3232 | + "endpoint_safety_net": True, |
| 3233 | + "yandex_error": ( |
| 3234 | + f"unexpected error in keyword bids endpoint: " |
| 3235 | + f"{type(exc).__name__}: {exc}" |
| 3236 | + ), |
| 3237 | + "exception_type": type(exc).__name__, |
| 3238 | + }, |
| 3239 | + ) |
| 3240 | + except Exception: |
| 3241 | + pass |
| 3242 | + raise HTTPException( |
| 3243 | + status_code=502, |
| 3244 | + detail={ |
| 3245 | + "error_type": "YandexDirectError", |
| 3246 | + "message": ( |
| 3247 | + f"unexpected error during keyword bids update: " |
| 3248 | + f"{type(exc).__name__}" |
| 3249 | + ), |
| 3250 | + }, |
| 3251 | + ) from exc |
| 3252 | + |
| 3253 | + |
| 3254 | +# --------------------------------------------------------------------------- |
| 3255 | +# Yandex Direct UTM — audit / plan / apply |
| 3256 | +# --------------------------------------------------------------------------- |
| 3257 | + |
| 3258 | + |
| 3259 | +@app.get( |
| 3260 | + "/yandex/campaigns/{campaign_id}/utm-audit", |
| 3261 | + response_model=UtmAuditResult, |
| 3262 | +) |
| 3263 | +def yandex_utm_audit( |
| 3264 | + campaign_id: str, |
| 3265 | + settings: Settings = Depends(get_settings), |
| 3266 | + client: YandexDirectClient | None = Depends(get_yandex_client), |
| 3267 | +) -> UtmAuditResult: |
| 3268 | + """Read-only UTM audit for all ad and sitelink URLs in a campaign. |
| 3269 | +
|
| 3270 | + In mock mode, returns deterministic mock data. In sandbox/live modes, |
| 3271 | + reads real ads and sitelinks from Yandex Direct (no writes). |
| 3272 | + """ |
| 3273 | + return store.utm_audit( |
| 3274 | + campaign_id, |
| 3275 | + settings=settings, |
| 3276 | + client=client, |
| 3277 | + ) |
| 3278 | + |
| 3279 | + |
| 3280 | +@app.post( |
| 3281 | + "/yandex/campaigns/{campaign_id}/utm-plan", |
| 3282 | + response_model=UtmPlanResult, |
| 3283 | +) |
| 3284 | +def yandex_utm_plan( |
| 3285 | + campaign_id: str, |
| 3286 | + payload: UtmPlanRequest, |
| 3287 | + settings: Settings = Depends(get_settings), |
| 3288 | + client: YandexDirectClient | None = Depends(get_yandex_client), |
| 3289 | +) -> UtmPlanResult: |
| 3290 | + """Generate UTM plan/preview — always dry_run, never writes. |
| 3291 | +
|
| 3292 | + Returns the list of URL changes (old → new with UTM) and a |
| 3293 | + preview of the v5 ``ads.update`` payload that WOULD be sent on apply. |
| 3294 | + Sitelink previews are included but apply is not_implemented. |
| 3295 | + """ |
| 3296 | + return store.utm_plan( |
| 3297 | + campaign_id, |
| 3298 | + payload, |
| 3299 | + settings=settings, |
| 3300 | + client=client, |
| 3301 | + ) |
| 3302 | + |
| 3303 | + |
| 3304 | +@app.post( |
| 3305 | + "/yandex/campaigns/{campaign_id}/utm-apply", |
| 3306 | + response_model=UtmApplyResult, |
| 3307 | +) |
| 3308 | +def yandex_utm_apply( |
| 3309 | + campaign_id: str, |
| 3310 | + payload: UtmApplyRequest, |
| 3311 | + settings: Settings = Depends(get_settings), |
| 3312 | + client: YandexDirectClient | None = Depends(get_yandex_client), |
| 3313 | +) -> UtmApplyResult: |
| 3314 | + """Apply UTM URLs to live ads — write-gated. |
| 3315 | +
|
| 3316 | + * ``dry_run=True`` → preview only, ``applied=False``. |
| 3317 | + * ``dry_run=False`` requires: |
| 3318 | + 1. ``DIRECTPILOT_MODE=live_write`` |
| 3319 | + 2. ``approved=true`` |
| 3320 | + 3. ``idempotency_key`` (>= 6 chars) |
| 3321 | +
|
| 3322 | + Uses ``ads.update`` (REPLACE-shaped) to safely update TextAd.Href. |
| 3323 | + Sitelink apply is not_implemented — surfaced in ``not_implemented``. |
| 3324 | + """ |
| 3325 | + if not payload.approved: |
| 3326 | + raise HTTPException( |
| 3327 | + status_code=409, |
| 3328 | + detail="Action requires explicit approval before UTM apply", |
| 3329 | + ) |
| 3330 | + if not payload.dry_run and settings.directpilot_mode != "live_write": |
| 3331 | + raise HTTPException( |
| 3332 | + status_code=409, |
| 3333 | + detail=( |
| 3334 | + f"Live writes require DIRECTPILOT_MODE=live_write; " |
| 3335 | + f"current mode is {settings.directpilot_mode!r}; " |
| 3336 | + f"UTM apply is not allowed in this mode " |
| 3337 | + f"(dry_run=True is the only allowed path)" |
| 3338 | + ), |
| 3339 | + ) |
| 3340 | + try: |
| 3341 | + return store.utm_apply( |
| 3342 | + campaign_id, |
| 3343 | + payload, |
| 3344 | + settings=settings, |
| 3345 | + client=client, |
| 3346 | + ) |
| 3347 | + except ValueError as exc: |
| 3348 | + raise HTTPException(status_code=409, detail=str(exc)) from exc |
| 3349 | + except YandexDirectError as exc: |
| 3350 | + raise _yandex_error_to_502(exc) from exc |
| 3351 | + |
| 3352 | + |
3136 | 3353 | # --------------------------------------------------------------------------- |
3137 | 3354 | # Yandex Metrika — read-only (counters, goals, summary, traffic-sources) |
3138 | 3355 | # |
|
0 commit comments