sklik rozsireni na typove metody
This commit is contained in:
@@ -133,6 +133,58 @@ class SklikClient:
|
||||
full_args = [self._user_struct()] + list(args or [])
|
||||
return await self._call(method, full_args)
|
||||
|
||||
async def fetch_list(
|
||||
self,
|
||||
entity: str,
|
||||
restriction_filter: dict[str, Any],
|
||||
display_columns: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Page through ``{entity}.list`` and collect every row.
|
||||
|
||||
``{entity}.list`` takes ``(restrictionFilter, displayOptions)`` and
|
||||
returns the rows under a key named after the entity (``campaigns``,
|
||||
``groups``, ``ads``). Paging is offset/limit, same as readReport.
|
||||
"""
|
||||
rows: list[Any] = []
|
||||
offset = 0
|
||||
pages = 0
|
||||
total: int | None = None
|
||||
|
||||
while True:
|
||||
display: dict[str, Any] = {
|
||||
"offset": offset,
|
||||
"limit": config.SKLIK_LIST_PAGE_LIMIT,
|
||||
}
|
||||
if display_columns:
|
||||
display["displayColumns"] = display_columns
|
||||
|
||||
page = await self.call(f"{entity}.list", [restriction_filter, display])
|
||||
batch = page.get(entity) or []
|
||||
rows.extend(batch)
|
||||
if total is None and isinstance(page.get("totalCount"), int):
|
||||
total = page["totalCount"]
|
||||
|
||||
pages += 1
|
||||
offset += config.SKLIK_LIST_PAGE_LIMIT
|
||||
if len(batch) < config.SKLIK_LIST_PAGE_LIMIT:
|
||||
break
|
||||
if pages >= config.SKLIK_LIST_MAX_PAGES:
|
||||
logger.warning(
|
||||
"Sklik %s.list hit the %d-page safety cap (collected %d "
|
||||
"rows); result is truncated.",
|
||||
entity,
|
||||
config.SKLIK_LIST_MAX_PAGES,
|
||||
len(rows),
|
||||
)
|
||||
break
|
||||
|
||||
return {
|
||||
"totalCount": total if total is not None else len(rows),
|
||||
"returnedCount": len(rows),
|
||||
"truncated": pages >= config.SKLIK_LIST_MAX_PAGES,
|
||||
entity: rows,
|
||||
}
|
||||
|
||||
async def fetch_report(
|
||||
self, entity: str, report_args: list[Any]
|
||||
) -> dict:
|
||||
|
||||
@@ -60,6 +60,47 @@ GOOGLE_ADS_SCOPE = os.getenv(
|
||||
# HTTP body is a JSON array of positional arguments.
|
||||
SKLIK_BASE_URL = os.getenv("SKLIK_BASE_URL", "https://api.sklik.cz/drak/json/v5")
|
||||
|
||||
# Page size / safety cap for the typed list endpoints (campaigns, groups, ads).
|
||||
SKLIK_LIST_PAGE_LIMIT = int(os.getenv("SKLIK_LIST_PAGE_LIMIT", "100"))
|
||||
SKLIK_LIST_MAX_PAGES = int(os.getenv("SKLIK_LIST_MAX_PAGES", "200"))
|
||||
|
||||
# --- Sklik write guard rails --------------------------------------------------
|
||||
# Optional backstops for the write endpoints. All amounts are in HALERS
|
||||
# (100 = 1 Kc), the unit the Sklik API itself uses.
|
||||
#
|
||||
# All THREE ceilings default to 0 = DISABLED, i.e. the proxy does not second-
|
||||
# guess the caller's budgets out of the box. Set a non-zero value to have the
|
||||
# proxy reject anything above it - useful as a safety net against a misplaced
|
||||
# decimal point (Sklik works in halers, so "50000" meant as korunas is 500 Kc).
|
||||
# Enforcement is opt-in on purpose: the approval flow lives on the caller's side.
|
||||
SKLIK_MAX_DAY_BUDGET_HALERS = int(os.getenv("SKLIK_MAX_DAY_BUDGET_HALERS", "0"))
|
||||
SKLIK_MAX_TOTAL_BUDGET_HALERS = int(os.getenv("SKLIK_MAX_TOTAL_BUDGET_HALERS", "0"))
|
||||
# Ceiling for a group's default max CPC / CPT.
|
||||
SKLIK_MAX_CPC_HALERS = int(os.getenv("SKLIK_MAX_CPC_HALERS", "0"))
|
||||
|
||||
# How long a completed write is remembered for idempotent replay (seconds).
|
||||
# Idempotency itself is opt-in per request via the X-Idempotency-Key header.
|
||||
SKLIK_IDEMPOTENCY_TTL_SECONDS = float(
|
||||
os.getenv("SKLIK_IDEMPOTENCY_TTL_SECONDS", "86400")
|
||||
)
|
||||
|
||||
# Creation always forces status=suspend (see app.sklik_guards). UPDATE, however,
|
||||
# is full CRUD and can set status=active by default - that is how you resume a
|
||||
# paused campaign. Set this to "true" to refuse activation through the API as
|
||||
# well, so starting a campaign stays a manual action in the Sklik UI.
|
||||
SKLIK_BLOCK_ACTIVATION = os.getenv(
|
||||
"SKLIK_BLOCK_ACTIVATION", "false"
|
||||
).strip().lower() in ("1", "true", "yes")
|
||||
|
||||
# The generic /sklik/rpc passthrough reaches any method, including mutating ones
|
||||
# (campaigns.create etc.), which bypass the typed endpoints' guard rails. That
|
||||
# is the long-standing behaviour and stays ALLOWED by default. Set this to
|
||||
# "false" to restrict the passthrough to read methods and funnel every write
|
||||
# through the typed endpoints.
|
||||
SKLIK_RPC_ALLOW_MUTATIONS = os.getenv(
|
||||
"SKLIK_RPC_ALLOW_MUTATIONS", "true"
|
||||
).strip().lower() in ("1", "true", "yes")
|
||||
|
||||
# --- HTTP ---------------------------------------------------------------------
|
||||
# Upstream request timeout in seconds.
|
||||
HTTP_TIMEOUT_SECONDS = float(os.getenv("HTTP_TIMEOUT_SECONDS", "60"))
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Idempotency for write operations.
|
||||
|
||||
Why this exists: if a create request times out on the network, the caller cannot
|
||||
know whether the campaign was created. Retrying without protection creates a
|
||||
second campaign that also spends money. An idempotency key makes the retry
|
||||
return the ORIGINAL result instead of doing the work twice.
|
||||
|
||||
Scope and honest limitations - this is an **in-memory, per-container** store:
|
||||
|
||||
* it does not survive a restart or a redeploy;
|
||||
* it is not shared between replicas, so with more than one container a retry
|
||||
can land on a replica that has never seen the key.
|
||||
|
||||
That is a deliberate trade-off matching the stateless design (the same one the
|
||||
Google token cache in ``clients/google.py`` makes). It removes the common
|
||||
failure - an immediate client retry after a timeout - and it is a large
|
||||
improvement over nothing. If Sklik writes ever run at a volume where a duplicated
|
||||
campaign is unacceptable, this needs to move to shared storage (Redis) and that
|
||||
should be a conscious decision, not a surprise. Keys are hashed together with the
|
||||
caller's token so two tenants can never read each other's stored responses.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from . import config
|
||||
from .errors import UpstreamError
|
||||
from .logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Entry:
|
||||
#: None while the original request is still running.
|
||||
response: Any
|
||||
created_at: float
|
||||
in_flight: bool
|
||||
|
||||
|
||||
_store: dict[str, _Entry] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _cache_key(idempotency_key: str, token: str, operation: str) -> str:
|
||||
# Hashed so neither the raw token nor the caller's key sits in a dict key we
|
||||
# might later log. The token is part of the key so tenants stay isolated.
|
||||
raw = f"{idempotency_key}|{token}|{operation}"
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _purge_expired(now: float) -> None:
|
||||
"""Drop entries past their TTL. Called under the lock."""
|
||||
ttl = config.SKLIK_IDEMPOTENCY_TTL_SECONDS
|
||||
expired = [k for k, e in _store.items() if now - e.created_at > ttl]
|
||||
for key in expired:
|
||||
del _store[key]
|
||||
|
||||
|
||||
def begin(idempotency_key: str, token: str, operation: str) -> tuple[str, Any | None]:
|
||||
"""Claim an idempotency key before performing a write.
|
||||
|
||||
Returns ``(cache_key, previous_response)``. A non-None previous response
|
||||
means this exact request already succeeded and must NOT be performed again -
|
||||
return the stored response instead.
|
||||
|
||||
Raises ``UpstreamError`` (409) if an identical request is still in flight,
|
||||
so two concurrent retries cannot both reach Sklik.
|
||||
"""
|
||||
key = _cache_key(idempotency_key, token, operation)
|
||||
now = time.time()
|
||||
with _lock:
|
||||
_purge_expired(now)
|
||||
entry = _store.get(key)
|
||||
if entry is None:
|
||||
_store[key] = _Entry(response=None, created_at=now, in_flight=True)
|
||||
return key, None
|
||||
if entry.in_flight:
|
||||
raise UpstreamError(
|
||||
"A request with this X-Idempotency-Key is still in progress. "
|
||||
"Wait for it to finish rather than retrying.",
|
||||
status=409,
|
||||
)
|
||||
logger.info(
|
||||
"Idempotent replay for %s (key hash %s...) - not calling Sklik again.",
|
||||
operation,
|
||||
key[:12],
|
||||
)
|
||||
return key, entry.response
|
||||
|
||||
|
||||
def complete(cache_key: str, response: Any) -> None:
|
||||
"""Store the successful result so a retry replays it."""
|
||||
with _lock:
|
||||
entry = _store.get(cache_key)
|
||||
if entry is None:
|
||||
# Purged mid-flight (very long request vs. a short TTL). Recreate it
|
||||
# rather than losing the record silently.
|
||||
_store[cache_key] = _Entry(
|
||||
response=response, created_at=time.time(), in_flight=False
|
||||
)
|
||||
logger.warning(
|
||||
"Idempotency entry %s... vanished before completion; recreated.",
|
||||
cache_key[:12],
|
||||
)
|
||||
return
|
||||
entry.response = response
|
||||
entry.in_flight = False
|
||||
|
||||
|
||||
def abandon(cache_key: str) -> None:
|
||||
"""Release a claimed key after a FAILED write, so the caller can retry.
|
||||
|
||||
A failed create must stay retryable: keeping the key would lock the caller
|
||||
out of ever retrying with it.
|
||||
"""
|
||||
with _lock:
|
||||
entry = _store.get(cache_key)
|
||||
if entry is not None and entry.in_flight:
|
||||
del _store[cache_key]
|
||||
+37
@@ -39,6 +39,7 @@ služba si nic neukládá. Vyplníte je v Swaggeru po kliknutí na **Try it out*
|
||||
| Google Ads | `X-GAds-Login-Customer-Id`, `X-GAds-Quota-Project` | ne |
|
||||
| Sklik | `X-Sklik-Token` | ano |
|
||||
| Sklik | `X-Sklik-User-Id` | ne (jen pro agenturní/MCC přístup) |
|
||||
| Sklik – zápis | `X-Idempotency-Key` | ne (doporučeno, viz níže) |
|
||||
|
||||
Tři Google služby používají stejný princip přihlášení (Google OAuth) – liší se
|
||||
jen prefixem hlavičky a oprávněním (scope). **Jeden service account lze použít
|
||||
@@ -115,6 +116,42 @@ standardní hlavičce `Authorization: Bearer <token>`.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Sklik – správa kampaní (zápis)
|
||||
|
||||
Plné CRUD nad kampaněmi, sestavami a inzeráty. Tělo je **pole struktur** podle
|
||||
[dokumentace Skliku](https://api.sklik.cz/drak/campaigns.create.html); dávka je
|
||||
all-or-nothing.
|
||||
|
||||
| Operace | Endpoint |
|
||||
| --- | --- |
|
||||
| Založení | `POST /sklik/{campaigns\\|groups\\|ads}` |
|
||||
| Úprava | `PUT /sklik/{campaigns\\|groups\\|ads}` (povinné `id`) |
|
||||
| Smazání | `DELETE /sklik/{campaigns\\|groups\\|ads}?ids=1,2` |
|
||||
| Obnovení | `POST /sklik/{campaigns\\|groups\\|ads}/restore?ids=1,2` |
|
||||
|
||||
- **Vše se zakládá pauznuté.** Sklik má výchozí `status: active`, takže
|
||||
neuvedený stav by znamenal živou kampaň – proxy proto při zakládání vždy
|
||||
vynutí `status: "suspend"` a jinou hodnotu ignoruje.
|
||||
- **`PUT` status nevynucuje** – tím se kampaň pozastavuje (`suspend`) i
|
||||
spouští (`active`). Pokud má aktivace zůstat výhradně ruční, nastavte
|
||||
`SKLIK_BLOCK_ACTIVATION=true`; pak je `active` odmítnuto s 403.
|
||||
- **Mazání je vratné** – Sklik entitu jen označí jako smazanou, `/restore` ji vrátí.
|
||||
- **U inzerátů** platí, že změna kreativy (nadpisy, popis, URL) starý inzerát
|
||||
smaže a založí nový – **inzerát dostane nové `id`**.
|
||||
- **Částky jsou v haléřích** (100 = 1 Kč). `dayBudget: 20000` = 200 Kč/den.
|
||||
- **`X-Idempotency-Key`** (volitelné, doporučené): při opakovaném odeslání se
|
||||
stejným klíčem se vrátí původní výsledek místo zopakování zápisu.
|
||||
- **Stropy rozpočtů** jsou ve výchozím stavu **vypnuté**. Zapínají se
|
||||
proměnnými `SKLIK_MAX_DAY_BUDGET_HALERS`, `SKLIK_MAX_TOTAL_BUDGET_HALERS`
|
||||
a `SKLIK_MAX_CPC_HALERS`. Co je aktuálně nastavené, ukáže
|
||||
`GET /sklik/write-limits`.
|
||||
|
||||
Číst strukturu účtu lze přes `GET /sklik/campaigns`, `/sklik/groups`,
|
||||
`/sklik/ads` (stránkování řeší proxy), statistiky přes `POST
|
||||
/sklik/report/{entity}`.
|
||||
|
||||
---
|
||||
|
||||
Podrobnosti k jednotlivým endpointům jsou v `documentation/` v repozitáři.
|
||||
""".strip()
|
||||
|
||||
|
||||
+523
-6
@@ -4,17 +4,34 @@ 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.
|
||||
|
||||
Credentials: X-Sklik-Token (required).
|
||||
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
|
||||
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"])
|
||||
|
||||
@@ -30,11 +47,69 @@ _REPORT_ENTITIES = {
|
||||
"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(
|
||||
@@ -55,6 +130,152 @@ def _optional_user_id(
|
||||
) 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),
|
||||
@@ -76,9 +297,121 @@ async def limits(
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
) -> Any:
|
||||
async with SklikClient(token, user_id=user_id) as client:
|
||||
return await client.call("api.limits")
|
||||
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)",
|
||||
@@ -105,9 +438,183 @@ async def report(
|
||||
status=400,
|
||||
)
|
||||
async with SklikClient(token, user_id=user_id) as client:
|
||||
return await client.fetch_report(entity, body)
|
||||
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)",
|
||||
@@ -116,7 +623,9 @@ async def rpc(
|
||||
method: str = Path(
|
||||
...,
|
||||
description="Sklik method name, e.g. campaigns.list, groups.list, "
|
||||
"ads.list, api.limits. (client.loginByToken is managed by the proxy.)",
|
||||
"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=[],
|
||||
@@ -127,5 +636,13 @@ async def rpc(
|
||||
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 await client.call(method, args)
|
||||
return _strip_session(await client.call(method, args))
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Guard rails for Sklik write operations.
|
||||
|
||||
1. **Everything is created paused.** This is a requirement of the integration:
|
||||
entities are created in a paused state and activation is always manual.
|
||||
Sklik's ``status`` defaults to ``active``, so an omitted field would silently
|
||||
produce a *live* campaign - the proxy therefore forces
|
||||
``status: "suspend"`` on every created entity. This one is not configurable;
|
||||
it is the point of the endpoint.
|
||||
2. **Budget ceilings - OPT-IN, disabled by default.** ``dayBudget`` /
|
||||
``totalBudget`` / ``cpc`` are only checked when the corresponding
|
||||
``SKLIK_MAX_*`` config value is non-zero. Out of the box the proxy passes the
|
||||
caller's numbers through untouched; approval lives on the caller's side.
|
||||
Enabling a ceiling catches the misplaced decimal point (Sklik works in
|
||||
halers, so "50000" meant as korunas is 500 Kc) before it reaches Sklik.
|
||||
3. **Idempotency - opt-in per request**, see ``app.idempotency``.
|
||||
|
||||
Basic shape validation (required fields, integer amounts) always applies: it
|
||||
turns a generic upstream rejection into a message that says what is wrong.
|
||||
|
||||
Amounts are in HALERS throughout (100 halers = 1 Kc), matching the Sklik API.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from . import config
|
||||
from .errors import UpstreamError
|
||||
from .logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
#: Sklik's paused state. The alternative is "active".
|
||||
PAUSED_STATUS = "suspend"
|
||||
ACTIVE_STATUS = "active"
|
||||
_VALID_STATUSES = (ACTIVE_STATUS, PAUSED_STATUS)
|
||||
|
||||
#: Money fields per entity: field name -> (config ceiling, human label).
|
||||
_MONEY_FIELDS: dict[str, dict[str, tuple[int, str]]] = {
|
||||
"campaigns": {
|
||||
"dayBudget": (config.SKLIK_MAX_DAY_BUDGET_HALERS, "daily budget"),
|
||||
"totalBudget": (config.SKLIK_MAX_TOTAL_BUDGET_HALERS, "total budget"),
|
||||
},
|
||||
"groups": {
|
||||
"cpc": (config.SKLIK_MAX_CPC_HALERS, "max CPC"),
|
||||
"cpt": (config.SKLIK_MAX_CPC_HALERS, "max CPT"),
|
||||
},
|
||||
"ads": {},
|
||||
}
|
||||
|
||||
#: Fields Sklik requires on create, checked here so the caller gets a clear
|
||||
#: message instead of a generic upstream rejection.
|
||||
_REQUIRED_FIELDS: dict[str, tuple[str, ...]] = {
|
||||
"campaigns": ("name", "dayBudget", "type"),
|
||||
"groups": ("campaignId", "name", "cpc"),
|
||||
"ads": ("groupId",),
|
||||
}
|
||||
|
||||
|
||||
def _halers_to_czk(value: int) -> str:
|
||||
return f"{value / 100:.2f} Kc"
|
||||
|
||||
|
||||
def _check_money(entity: str, index: int, clean: dict) -> None:
|
||||
"""Reject amounts above a configured ceiling. 0 in config = no ceiling."""
|
||||
for field, (ceiling, label) in _MONEY_FIELDS.get(entity, {}).items():
|
||||
value = clean.get(field)
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise UpstreamError(
|
||||
f"Item {index}: '{field}' must be an integer amount in halers "
|
||||
"(100 halers = 1 Kc).",
|
||||
status=400,
|
||||
)
|
||||
if value < 0:
|
||||
raise UpstreamError(
|
||||
f"Item {index}: '{field}' must not be negative.", status=400
|
||||
)
|
||||
if ceiling > 0 and value > ceiling:
|
||||
raise UpstreamError(
|
||||
f"Item {index}: {label} {_halers_to_czk(value)} exceeds the "
|
||||
f"configured ceiling {_halers_to_czk(ceiling)}. Amounts are in "
|
||||
"halers (100 = 1 Kc) - check for a misplaced decimal point, or "
|
||||
"raise the limit in the service configuration.",
|
||||
status=400,
|
||||
)
|
||||
|
||||
|
||||
def prepare_for_create(entity: str, items: list[Any]) -> list[dict]:
|
||||
"""Validate and normalize a batch of entities for ``{entity}.create``.
|
||||
|
||||
Returns a new list; the caller's input is never mutated. Raises
|
||||
``UpstreamError`` (400) on the first problem, naming the offending item's
|
||||
index so a batch failure is diagnosable.
|
||||
"""
|
||||
if not isinstance(items, list) or not items:
|
||||
raise UpstreamError(
|
||||
f"Provide a non-empty JSON array of {entity} to create.", status=400
|
||||
)
|
||||
|
||||
prepared: list[dict] = []
|
||||
for index, item in enumerate(items):
|
||||
if not isinstance(item, dict):
|
||||
raise UpstreamError(
|
||||
f"Item {index} must be a JSON object describing one "
|
||||
f"{entity[:-1]}.",
|
||||
status=400,
|
||||
)
|
||||
|
||||
for field in _REQUIRED_FIELDS.get(entity, ()):
|
||||
if item.get(field) in (None, ""):
|
||||
raise UpstreamError(
|
||||
f"Item {index}: '{field}' is required when creating "
|
||||
f"{entity}.",
|
||||
status=400,
|
||||
)
|
||||
|
||||
clean = dict(item)
|
||||
|
||||
# Rule 1: always paused, never negotiable.
|
||||
requested = clean.get("status")
|
||||
if requested is not None and requested != PAUSED_STATUS:
|
||||
# Not an error - we simply override it - but it must be visible.
|
||||
logger.warning(
|
||||
"Item %d requested status=%r for %s; forcing %r. This proxy "
|
||||
"cannot create active entities.",
|
||||
index,
|
||||
requested,
|
||||
entity,
|
||||
PAUSED_STATUS,
|
||||
)
|
||||
clean["status"] = PAUSED_STATUS
|
||||
|
||||
# Rule 2: money ceilings - only where one is configured (0 = disabled).
|
||||
_check_money(entity, index, clean)
|
||||
|
||||
prepared.append(clean)
|
||||
|
||||
logger.info(
|
||||
"Prepared %d %s for creation (all forced to status=%s).",
|
||||
len(prepared),
|
||||
entity,
|
||||
PAUSED_STATUS,
|
||||
)
|
||||
return prepared
|
||||
|
||||
|
||||
def prepare_for_update(entity: str, items: list[Any]) -> list[dict]:
|
||||
"""Validate a batch of entities for ``{entity}.update``.
|
||||
|
||||
Unlike create, update does NOT force a status: changing ``status`` is how a
|
||||
campaign is paused or resumed, and this is ordinary CRUD. Only two checks
|
||||
apply - the money ceilings (when configured) and, if the operator enabled
|
||||
``SKLIK_BLOCK_ACTIVATION``, a refusal to set ``status: "active"`` so that
|
||||
starting a campaign stays a manual action.
|
||||
|
||||
``id`` is required on every item; everything else is optional and only the
|
||||
supplied fields are changed upstream.
|
||||
"""
|
||||
if not isinstance(items, list) or not items:
|
||||
raise UpstreamError(
|
||||
f"Provide a non-empty JSON array of {entity} to update.", status=400
|
||||
)
|
||||
|
||||
prepared: list[dict] = []
|
||||
for index, item in enumerate(items):
|
||||
if not isinstance(item, dict):
|
||||
raise UpstreamError(
|
||||
f"Item {index} must be a JSON object with an 'id' and the "
|
||||
"fields to change.",
|
||||
status=400,
|
||||
)
|
||||
if item.get("id") in (None, ""):
|
||||
raise UpstreamError(
|
||||
f"Item {index}: 'id' is required when updating {entity}.",
|
||||
status=400,
|
||||
)
|
||||
|
||||
clean = dict(item)
|
||||
status = clean.get("status")
|
||||
if status is not None:
|
||||
if status not in _VALID_STATUSES:
|
||||
raise UpstreamError(
|
||||
f"Item {index}: status must be one of "
|
||||
+ ", ".join(_VALID_STATUSES)
|
||||
+ f" (got {status!r}).",
|
||||
status=400,
|
||||
)
|
||||
if status == ACTIVE_STATUS and config.SKLIK_BLOCK_ACTIVATION:
|
||||
raise UpstreamError(
|
||||
f"Item {index}: activating entities through the API is "
|
||||
"disabled (SKLIK_BLOCK_ACTIVATION). Start the campaign "
|
||||
"manually in the Sklik UI.",
|
||||
status=403,
|
||||
)
|
||||
|
||||
_check_money(entity, index, clean)
|
||||
prepared.append(clean)
|
||||
|
||||
activating = sum(1 for i in prepared if i.get("status") == ACTIVE_STATUS)
|
||||
if activating:
|
||||
# Activation starts spending - always visible in the log.
|
||||
logger.warning(
|
||||
"Updating %d %s to status=active (spending may start).",
|
||||
activating,
|
||||
entity,
|
||||
)
|
||||
logger.info("Prepared %d %s for update.", len(prepared), entity)
|
||||
return prepared
|
||||
|
||||
|
||||
def parse_ids(raw_ids: list[Any], entity: str) -> list[int]:
|
||||
"""Validate a list of entity ids for remove/restore."""
|
||||
if not isinstance(raw_ids, list) or not raw_ids:
|
||||
raise UpstreamError(
|
||||
f"Provide a non-empty list of {entity} ids.", status=400
|
||||
)
|
||||
ids: list[int] = []
|
||||
for index, value in enumerate(raw_ids):
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise UpstreamError(
|
||||
f"Id at position {index} must be an integer (got {value!r}).",
|
||||
status=400,
|
||||
)
|
||||
ids.append(value)
|
||||
return ids
|
||||
|
||||
|
||||
def _ceiling(value: int) -> int | None:
|
||||
"""0 in config means "no ceiling"; report that as null, not as zero."""
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
def budget_limits() -> dict[str, Any]:
|
||||
"""The active ceilings, so callers can read them instead of guessing."""
|
||||
return {
|
||||
"currency": "CZK",
|
||||
"unit": "haler (100 = 1 Kc)",
|
||||
"maxDayBudget": _ceiling(config.SKLIK_MAX_DAY_BUDGET_HALERS),
|
||||
"maxTotalBudget": _ceiling(config.SKLIK_MAX_TOTAL_BUDGET_HALERS),
|
||||
"maxCpc": _ceiling(config.SKLIK_MAX_CPC_HALERS),
|
||||
"budgetLimitsEnforced": any(
|
||||
v > 0
|
||||
for v in (
|
||||
config.SKLIK_MAX_DAY_BUDGET_HALERS,
|
||||
config.SKLIK_MAX_TOTAL_BUDGET_HALERS,
|
||||
config.SKLIK_MAX_CPC_HALERS,
|
||||
)
|
||||
),
|
||||
"createdStatus": PAUSED_STATUS,
|
||||
"activationBlocked": config.SKLIK_BLOCK_ACTIVATION,
|
||||
"note": (
|
||||
"Everything created through this proxy is paused. null ceilings "
|
||||
"mean no budget limit is enforced here - configure SKLIK_MAX_* to "
|
||||
"enable one. Update can set status=active unless "
|
||||
"activationBlocked is true."
|
||||
),
|
||||
}
|
||||
Reference in New Issue
Block a user