"""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 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", ], } _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"}] 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 async def _create( entity: str, items: list[Any], token: str, user_id: int | None, idem_key: str | None, ) -> Any: prepared = sklik_guards.prepare_for_create(entity, 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[Any], token: str, user_id: int | None, idem_key: str | None, ) -> Any: prepared = sklik_guards.prepare_for_update(entity, 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)) # --- 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[dict[str, Any]] = Body( ..., examples=[_CAMPAIGN_CREATE_EXAMPLE], description="Array of campaign structs. 'status' is ignored and forced " "to 'suspend'. Budgets are in halers (100 = 1 Kc). Required per item: " "name, type, dayBudget. 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[dict[str, Any]] = Body( ..., examples=[_GROUP_CREATE_EXAMPLE], description="Array of group structs. 'status' is forced to 'suspend'. " "cpc is in halers. Required per item: campaignId, name, cpc.", ), 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[dict[str, Any]] = Body( ..., examples=[_AD_CREATE_EXAMPLE], description="Array of ad structs. 'status' is forced to 'suspend'. " "Required per item: groupId (plus headline1/headline2/description/" "finalUrl for the default 'eta' ad type).", ), 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) # --- 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[dict[str, Any]] = Body( ..., examples=[_CAMPAIGN_UPDATE_EXAMPLE], description="Array of campaign structs. 'id' is required per item; every " "other field is optional and only the supplied ones change. 'status' " "accepts 'active' or 'suspend'. 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[dict[str, Any]] = Body( ..., examples=[_GROUP_UPDATE_EXAMPLE], description="Array of group structs. 'id' required; name, status, cpc, " "cpt, maxUserDailyImpression and devicesPriceRatio are updatable.", ), 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[dict[str, Any]] = Body( ..., examples=[_AD_UPDATE_EXAMPLE], description="Array of ad structs. 'id' required. NOTE: changing the " "creative (headlines, description, URLs) makes Sklik delete the old ad " "and create a new one with a NEW id - re-read the ad 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) # --- 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.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))