Files
2026-07-20 09:02:42 +02:00

766 lines
27 KiB
Python

"""Sklik (Seznam) - JSON-RPC proxy.
The proxy logs in with X-Sklik-Token per request (client.loginByToken) and then
performs the requested call, injecting the session for you. See
``app.clients.sklik_client`` and https://api.sklik.cz/drak/ for methods.
Three groups of endpoints:
* **read** - typed listing of campaigns / groups / ads, plus the existing
stats report helper;
* **write** - full CRUD over campaigns / groups / ads: create, update, remove
and restore. Creation always forces a **paused** status; update is ordinary
CRUD and can also resume a campaign. Removal is a soft delete, so every
remove has a matching restore. Budget ceilings and idempotency are available
but optional - see ``app.sklik_guards`` and ``app.idempotency``;
* **generic RPC** - anything else, including mutating methods. Unrestricted by
default; set ``SKLIK_RPC_ALLOW_MUTATIONS=false`` to funnel every write
through the typed endpoints instead.
Credentials: X-Sklik-Token (required), X-Sklik-User-Id (optional).
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body, Depends, Header, Path, Query
from .. import config, idempotency, sklik_guards
from ..clients.sklik_client import SklikClient
from ..credentials import get_sklik_token
from ..errors import UpstreamError
from ..logging_config import get_logger
from ..sklik_models import (
AdCreate,
AdUpdate,
CampaignCreate,
CampaignUpdate,
GroupCreate,
GroupUpdate,
KeywordCreate,
KeywordUpdate,
SklikStruct,
)
logger = get_logger(__name__)
router = APIRouter(prefix="/sklik", tags=["sklik"])
# Entities that expose createReport/readReport for the report helper.
_REPORT_ENTITIES = {
"campaigns",
"groups",
"ads",
"keywords",
"queries",
"sitelinks",
"productSets",
"banners",
}
# Method-name suffixes that change data. Allowed through the generic RPC
# passthrough by default; refused only when an operator sets
# SKLIK_RPC_ALLOW_MUTATIONS=false to force writes through the typed endpoints.
_MUTATING_SUFFIXES = (
".create",
".update",
".remove",
".delete",
".restore",
".setStatus",
)
_REPORT_EXAMPLE = [
{"dateFrom": "2026-06-01", "dateTo": "2026-06-18", "statGranularity": "daily"},
{"statGranularity": "daily"},
]
# Sensible default columns so a caller who does not know the Sklik schema still
# gets useful rows. Callers can override with display_columns.
_DEFAULT_COLUMNS = {
"campaigns": [
"id", "name", "status", "type", "dayBudget", "totalBudget",
"totalClicks", "startDate", "endDate", "createDate", "deleted",
],
"groups": [
"id", "name", "status", "maxCpc", "maxCpt", "campaign.id",
"campaign.name", "createDate", "deleted",
],
"ads": [
"id", "name", "status", "adType", "headline1", "headline2", "headline3",
"description", "description2", "finalUrl", "group.id", "group.name",
"campaign.id", "campaign.name", "createDate", "deleted",
],
"keywords": [
"id", "name", "status", "matchType", "cpc", "url", "disabled",
"group.id", "group.name", "campaign.id", "campaign.name",
"createDate", "deleted",
],
}
_CAMPAIGN_CREATE_EXAMPLE = [
{
"name": "Test kampan - leto 2026",
"type": "fulltext",
"dayBudget": 20000,
"totalBudget": 200000,
}
]
_GROUP_CREATE_EXAMPLE = [
{"campaignId": 123456, "name": "Sestava A", "cpc": 300}
]
_AD_CREATE_EXAMPLE = [
{
"groupId": 654321,
"adType": "eta",
"headline1": "Nadpis jedna",
"headline2": "Nadpis dva",
"description": "Popis inzeratu.",
"finalUrl": "https://example.com/",
}
]
_CAMPAIGN_UPDATE_EXAMPLE = [{"id": 123456, "dayBudget": 30000, "status": "suspend"}]
_GROUP_UPDATE_EXAMPLE = [{"id": 654321, "cpc": 450}]
_AD_UPDATE_EXAMPLE = [{"id": 987654, "status": "suspend"}]
_KEYWORD_CREATE_EXAMPLE = [
{"groupId": 654321, "name": "levne boty", "matchType": "phrase", "cpc": 300},
{"groupId": 654321, "name": "damske boty", "matchType": "exact"},
]
_KEYWORD_UPDATE_EXAMPLE = [{"id": 555001, "cpc": 400}]
def _optional_user_id(
x_sklik_user_id: str | None = Header(
default=None,
alias="X-Sklik-User-Id",
description="Optional managed account id (userId) to act on behalf of, "
"for agency/MCC access.",
),
) -> int | None:
raw = (x_sklik_user_id or "").strip()
if not raw:
return None
try:
return int(raw)
except ValueError as exc:
raise UpstreamError(
"X-Sklik-User-Id must be an integer.", status=400
) from exc
def _idempotency_key(
x_idempotency_key: str | None = Header(
default=None,
alias="X-Idempotency-Key",
description="Optional. Any unique string per logical operation (e.g. a "
"UUID). When supplied, retrying with the same key returns the original "
"result instead of creating a duplicate - recommended, because a "
"network timeout otherwise leaves you unable to tell whether the "
"campaign was created. Without it every call is executed as sent.",
),
) -> str | None:
return (x_idempotency_key or "").strip() or None
def _strip_session(payload: Any) -> Any:
"""Remove the Sklik session from anything we hand back to the caller.
The session is a credential; AGENTS.md forbids returning secrets from
ordinary endpoints. The proxy manages it internally, so the caller has no
use for it.
"""
if isinstance(payload, dict) and "session" in payload:
return {k: v for k, v in payload.items() if k != "session"}
return payload
def _id_list(raw: str | None, field: str) -> list[int] | None:
"""Parse a comma-separated id list into ints."""
if not raw or not raw.strip():
return None
try:
return [int(part) for part in raw.split(",") if part.strip()]
except ValueError as exc:
raise UpstreamError(
f"{field} must be a comma-separated list of integer ids.", status=400
) from exc
async def _mutate(
operation: str,
args: list[Any],
token: str,
user_id: int | None,
idem_key: str | None,
extra: dict[str, Any] | None = None,
) -> Any:
"""Shared write flow: (claim key) -> call Sklik -> remember the result.
Idempotency is applied only when the caller sent X-Idempotency-Key; without
it the call is executed exactly as received.
"""
cache_key: str | None = None
if idem_key:
cache_key, previous = idempotency.begin(idem_key, token, operation)
if previous is not None:
return {**previous, "idempotentReplay": True}
try:
async with SklikClient(token, user_id=user_id) as client:
result = await client.call(operation, args)
except Exception:
# A failed write must stay retryable with the same key.
if cache_key:
idempotency.abandon(cache_key)
raise
payload = dict(_strip_session(result))
payload.update(extra or {})
payload["idempotentReplay"] = False
if cache_key:
idempotency.complete(cache_key, payload)
return payload
def _structs(items: list[SklikStruct]) -> list[dict[str, Any]]:
"""Model -> Sklik struct, keeping only the fields the caller actually sent."""
return [item.to_sklik() for item in items]
async def _create(
entity: str,
items: list[SklikStruct],
token: str,
user_id: int | None,
idem_key: str | None,
) -> Any:
prepared = sklik_guards.prepare_for_create(entity, _structs(items))
return await _mutate(
f"{entity}.create",
[prepared],
token,
user_id,
idem_key,
extra={
"createdCount": len(prepared),
"createdStatus": sklik_guards.PAUSED_STATUS,
"note": (
"Created paused. Activate manually in the Sklik UI - this proxy "
"cannot create active entities."
),
},
)
async def _update(
entity: str,
items: list[SklikStruct],
token: str,
user_id: int | None,
idem_key: str | None,
) -> Any:
prepared = sklik_guards.prepare_for_update(entity, _structs(items))
return await _mutate(
f"{entity}.update",
[prepared],
token,
user_id,
idem_key,
extra={"updatedCount": len(prepared)},
)
async def _remove_or_restore(
entity: str,
action: str,
ids: str | None,
token: str,
user_id: int | None,
idem_key: str | None,
) -> Any:
parsed = sklik_guards.parse_ids(_id_list(ids, "ids") or [], entity)
return await _mutate(
f"{entity}.{action}",
[parsed],
token,
user_id,
idem_key,
extra={
f"{action}dCount": len(parsed),
"ids": parsed,
"note": (
"Sklik removal is reversible - the entity is only marked as "
"removed. Use the restore endpoint to bring it back."
if action == "remove"
else "Restored previously removed entities."
),
},
)
# --- Session / account --------------------------------------------------------
@router.post("/login", summary="Verify the Sklik token (client.loginByToken)")
async def login(
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
) -> dict:
"""Check the token works. The session itself is internal and not returned."""
async with SklikClient(token, user_id=user_id) as client:
payload = await client.login()
return {
"valid": True,
"status": payload.get("status"),
"statusMessage": payload.get("statusMessage"),
}
@router.get("/limits", summary="API limits and quota (api.limits)")
async def limits(
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
) -> Any:
async with SklikClient(token, user_id=user_id) as client:
return _strip_session(await client.call("api.limits"))
@router.get(
"/write-limits",
summary="Guard rails applied to writes (budget ceilings, forced status)",
)
def write_limits() -> dict:
"""Read the ceilings this proxy enforces. No Sklik call, no credentials."""
return sklik_guards.budget_limits()
# --- Read: entity listing -----------------------------------------------------
@router.get("/campaigns", summary="List campaigns (campaigns.list)")
async def list_campaigns(
ids: str | None = Query(None, description="Comma-separated campaign ids."),
is_deleted: bool | None = Query(
None, description="Filter deleted/undeleted. Omit to get both."
),
display_columns: str | None = Query(
None,
description="Comma-separated columns to return. Defaults to a useful "
"subset; see https://api.sklik.cz/drak/campaigns.list.html.",
),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
) -> Any:
"""All pages are collected for you (offset/limit paging is handled here).
Note: Sklik's campaigns.list filter supports only ``ids`` and ``isDeleted``
- there is no status filter upstream, so filter on ``status`` in the result.
"""
restriction: dict[str, Any] = {}
parsed = _id_list(ids, "ids")
if parsed:
restriction["ids"] = parsed
if is_deleted is not None:
restriction["isDeleted"] = is_deleted
columns = (
[c.strip() for c in display_columns.split(",") if c.strip()]
if display_columns
else _DEFAULT_COLUMNS["campaigns"]
)
async with SklikClient(token, user_id=user_id) as client:
return _strip_session(await client.fetch_list("campaigns", restriction, columns))
@router.get("/groups", summary="List groups/ad sets (groups.list)")
async def list_groups(
campaign_ids: str | None = Query(
None, description="Comma-separated campaign ids to list groups from."
),
ids: str | None = Query(None, description="Comma-separated group ids."),
is_deleted: bool | None = Query(None),
display_columns: str | None = Query(None),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
) -> Any:
restriction: dict[str, Any] = {}
parsed = _id_list(ids, "ids")
if parsed:
restriction["ids"] = parsed
campaigns = _id_list(campaign_ids, "campaign_ids")
if campaigns:
restriction["campaign"] = {"ids": campaigns}
if is_deleted is not None:
restriction["isDeleted"] = is_deleted
columns = (
[c.strip() for c in display_columns.split(",") if c.strip()]
if display_columns
else _DEFAULT_COLUMNS["groups"]
)
async with SklikClient(token, user_id=user_id) as client:
return _strip_session(await client.fetch_list("groups", restriction, columns))
@router.get("/ads", summary="List ads (ads.list)")
async def list_ads(
campaign_ids: str | None = Query(
None, description="Comma-separated campaign ids to list ads from."
),
group_ids: str | None = Query(
None, description="Comma-separated group ids to list ads from."
),
ids: str | None = Query(None, description="Comma-separated ad ids."),
is_deleted: bool | None = Query(None),
display_columns: str | None = Query(None),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
) -> Any:
restriction: dict[str, Any] = {}
parsed = _id_list(ids, "ids")
if parsed:
restriction["ids"] = parsed
campaigns = _id_list(campaign_ids, "campaign_ids")
if campaigns:
restriction["campaign"] = {"ids": campaigns}
groups = _id_list(group_ids, "group_ids")
if groups:
restriction["group"] = {"ids": groups}
if is_deleted is not None:
restriction["isDeleted"] = is_deleted
columns = (
[c.strip() for c in display_columns.split(",") if c.strip()]
if display_columns
else _DEFAULT_COLUMNS["ads"]
)
async with SklikClient(token, user_id=user_id) as client:
return _strip_session(await client.fetch_list("ads", restriction, columns))
@router.get("/keywords", summary="List keywords (keywords.list)")
async def list_keywords(
campaign_ids: str | None = Query(
None, description="Comma-separated campaign ids to list keywords from."
),
group_ids: str | None = Query(
None, description="Comma-separated group ids to list keywords from."
),
ids: str | None = Query(None, description="Comma-separated keyword ids."),
is_deleted: bool | None = Query(None),
display_columns: str | None = Query(None),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
) -> Any:
restriction: dict[str, Any] = {}
parsed = _id_list(ids, "ids")
if parsed:
restriction["ids"] = parsed
campaigns = _id_list(campaign_ids, "campaign_ids")
if campaigns:
restriction["campaign"] = {"ids": campaigns}
groups = _id_list(group_ids, "group_ids")
if groups:
restriction["group"] = {"ids": groups}
if is_deleted is not None:
restriction["isDeleted"] = is_deleted
columns = (
[c.strip() for c in display_columns.split(",") if c.strip()]
if display_columns
else _DEFAULT_COLUMNS["keywords"]
)
async with SklikClient(token, user_id=user_id) as client:
return _strip_session(await client.fetch_list("keywords", restriction, columns))
# --- Read: statistics ---------------------------------------------------------
@router.post(
"/report/{entity}",
summary="Create and read a Sklik stats report (createReport + readReport)",
)
async def report(
entity: str = Path(
...,
description="Entity to report on: "
+ ", ".join(sorted(_REPORT_ENTITIES)),
),
body: list[Any] = Body(
...,
examples=[_REPORT_EXAMPLE],
description="Arguments for {entity}.createReport (restriction filter and "
"optional display options). The session is injected automatically.",
),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
) -> Any:
if entity not in _REPORT_ENTITIES:
raise UpstreamError(
f"Unsupported report entity '{entity}'. Allowed: "
+ ", ".join(sorted(_REPORT_ENTITIES)),
status=400,
)
async with SklikClient(token, user_id=user_id) as client:
return _strip_session(await client.fetch_report(entity, body))
# --- Write: creation (always paused) ------------------------------------------
@router.post("/campaigns", summary="Create campaigns — always PAUSED")
async def create_campaigns(
body: list[CampaignCreate] = Body(
...,
examples=[_CAMPAIGN_CREATE_EXAMPLE],
description="Campaigns to create. Budgets are in halers (100 = 1 Kc). "
"'status' cannot be set - creation always forces 'suspend'. Fields not "
"listed in the schema are forwarded to Sklik as-is. Sklik batches are "
"all-or-nothing.",
),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
idem_key: str | None = Depends(_idempotency_key),
) -> Any:
"""Create one or more campaigns in a paused state.
Sklik defaults ``status`` to *active*, so an omitted field would create a
live campaign - the proxy always overrides it to ``suspend``. Activation is
manual in the Sklik UI.
"""
return await _create("campaigns", body, token, user_id, idem_key)
@router.post("/groups", summary="Create groups/ad sets — always PAUSED")
async def create_groups(
body: list[GroupCreate] = Body(
...,
examples=[_GROUP_CREATE_EXAMPLE],
description="Groups to create. cpc is in halers (100 = 1 Kc). 'status' "
"cannot be set - creation always forces 'suspend'.",
),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
idem_key: str | None = Depends(_idempotency_key),
) -> Any:
return await _create("groups", body, token, user_id, idem_key)
@router.post("/ads", summary="Create ads — always PAUSED")
async def create_ads(
body: list[AdCreate] = Body(
...,
examples=[_AD_CREATE_EXAMPLE],
description="Ads to create. 'status' cannot be set - creation always "
"forces 'suspend'. For the default 'eta' type Sklik also requires "
"headline1, headline2, description and finalUrl.",
),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
idem_key: str | None = Depends(_idempotency_key),
) -> Any:
return await _create("ads", body, token, user_id, idem_key)
@router.post("/keywords", summary="Create keywords")
async def create_keywords(
body: list[KeywordCreate] = Body(
...,
examples=[_KEYWORD_CREATE_EXAMPLE],
description="Keywords to create. cpc is in halers (100 = 1 Kc); omit it "
"to use the group's default. Unlike campaigns/groups/ads, 'status' is "
"NOT forced here - a keyword cannot spend anything on its own, since "
"the campaign, group and ad above it are all created paused. Sklik "
"defaults it to 'active'. Returns positiveKeywordIds.",
),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
idem_key: str | None = Depends(_idempotency_key),
) -> Any:
return await _create("keywords", body, token, user_id, idem_key)
# --- Write: update ------------------------------------------------------------
# Update is ordinary CRUD: only the supplied fields change and 'status' is NOT
# forced, because changing it is how a campaign is paused or resumed. Set
# SKLIK_BLOCK_ACTIVATION=true to refuse status="active" here as well.
@router.put("/campaigns", summary="Update campaigns (partial, by id)")
async def update_campaigns(
body: list[CampaignUpdate] = Body(
...,
examples=[_CAMPAIGN_UPDATE_EXAMPLE],
description="'id' is required per item; every other field is optional "
"and ONLY the fields you send are changed. status='active' resumes the "
"campaign and spending starts. Budgets are in halers. 'type' cannot be "
"changed.",
),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
idem_key: str | None = Depends(_idempotency_key),
) -> Any:
return await _update("campaigns", body, token, user_id, idem_key)
@router.put("/groups", summary="Update groups/ad sets (partial, by id)")
async def update_groups(
body: list[GroupUpdate] = Body(
...,
examples=[_GROUP_UPDATE_EXAMPLE],
description="'id' required; only the fields you send are changed.",
),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
idem_key: str | None = Depends(_idempotency_key),
) -> Any:
return await _update("groups", body, token, user_id, idem_key)
@router.put("/ads", summary="Update ads (partial, by id)")
async def update_ads(
body: list[AdUpdate] = Body(
...,
examples=[_AD_UPDATE_EXAMPLE],
description="'id' required; only the fields you send are changed. NOTE: "
"changing the creative (headlines, description, URLs) makes Sklik delete "
"the old ad and create a new one with a NEW id - re-read it afterwards.",
),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
idem_key: str | None = Depends(_idempotency_key),
) -> Any:
return await _update("ads", body, token, user_id, idem_key)
@router.put("/keywords", summary="Update keywords (partial, by id)")
async def update_keywords(
body: list[KeywordUpdate] = Body(
...,
examples=[_KEYWORD_UPDATE_EXAMPLE],
description="'id' required; only cpc, url and status can be changed. "
"The keyword text and matchType are immutable in Sklik - remove the "
"keyword and create a new one instead.",
),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
idem_key: str | None = Depends(_idempotency_key),
) -> Any:
return await _update("keywords", body, token, user_id, idem_key)
# --- Write: remove / restore --------------------------------------------------
# Sklik removal is a soft delete ("marked as removed"), so every remove has a
# matching restore.
@router.delete("/campaigns", summary="Remove campaigns (reversible)")
async def remove_campaigns(
ids: str = Query(..., description="Comma-separated campaign ids to remove."),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
idem_key: str | None = Depends(_idempotency_key),
) -> Any:
return await _remove_or_restore(
"campaigns", "remove", ids, token, user_id, idem_key
)
@router.delete("/groups", summary="Remove groups/ad sets (reversible)")
async def remove_groups(
ids: str = Query(..., description="Comma-separated group ids to remove."),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
idem_key: str | None = Depends(_idempotency_key),
) -> Any:
return await _remove_or_restore("groups", "remove", ids, token, user_id, idem_key)
@router.delete("/ads", summary="Remove ads (reversible)")
async def remove_ads(
ids: str = Query(..., description="Comma-separated ad ids to remove."),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
idem_key: str | None = Depends(_idempotency_key),
) -> Any:
return await _remove_or_restore("ads", "remove", ids, token, user_id, idem_key)
@router.delete("/keywords", summary="Remove keywords (reversible)")
async def remove_keywords(
ids: str = Query(..., description="Comma-separated keyword ids to remove."),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
idem_key: str | None = Depends(_idempotency_key),
) -> Any:
return await _remove_or_restore("keywords", "remove", ids, token, user_id, idem_key)
@router.post("/keywords/restore", summary="Restore removed keywords")
async def restore_keywords(
ids: str = Query(..., description="Comma-separated keyword ids to restore."),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
idem_key: str | None = Depends(_idempotency_key),
) -> Any:
return await _remove_or_restore("keywords", "restore", ids, token, user_id, idem_key)
@router.post("/campaigns/restore", summary="Restore removed campaigns")
async def restore_campaigns(
ids: str = Query(..., description="Comma-separated campaign ids to restore."),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
idem_key: str | None = Depends(_idempotency_key),
) -> Any:
return await _remove_or_restore(
"campaigns", "restore", ids, token, user_id, idem_key
)
@router.post("/groups/restore", summary="Restore removed groups/ad sets")
async def restore_groups(
ids: str = Query(..., description="Comma-separated group ids to restore."),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
idem_key: str | None = Depends(_idempotency_key),
) -> Any:
return await _remove_or_restore("groups", "restore", ids, token, user_id, idem_key)
@router.post("/ads/restore", summary="Restore removed ads")
async def restore_ads(
ids: str = Query(..., description="Comma-separated ad ids to restore."),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
idem_key: str | None = Depends(_idempotency_key),
) -> Any:
return await _remove_or_restore("ads", "restore", ids, token, user_id, idem_key)
# --- Generic passthrough ------------------------------------------------------
@router.post(
"/rpc/{method}",
summary="Generic authenticated Sklik call (any method)",
)
async def rpc(
method: str = Path(
...,
description="Sklik method name, e.g. campaigns.list, groups.list, "
"ads.list, api.limits. Mutating methods work too, but bypass the typed "
"write endpoints' paused-status and budget guard rails. "
"(client.loginByToken is managed by the proxy.)",
),
args: list[Any] = Body(
default=[],
description="Positional arguments AFTER the session struct (which the "
"proxy injects as the first argument). Example for campaigns.list: "
'[{"statuses": ["active"]}, {"displayColumns": ["id","name"]}]',
),
token: str = Depends(get_sklik_token),
user_id: int | None = Depends(_optional_user_id),
) -> Any:
if not config.SKLIK_RPC_ALLOW_MUTATIONS and method.endswith(_MUTATING_SUFFIXES):
raise UpstreamError(
f"'{method}' modifies data and is not allowed through the generic "
"RPC passthrough, which has no budget or idempotency guard rails. "
"Use POST /sklik/campaigns, /sklik/groups or /sklik/ads instead. "
"(An operator can override this with SKLIK_RPC_ALLOW_MUTATIONS.)",
status=403,
)
async with SklikClient(token, user_id=user_id) as client:
return _strip_session(await client.call(method, args))