Files
analytics/app/sklik_guards.py
2026-07-20 09:02:42 +02:00

285 lines
11 KiB
Python

"""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": {},
"keywords": {"cpc": (config.SKLIK_MAX_CPC_HALERS, "max CPC")},
}
#: 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",),
"keywords": ("name", "groupId"),
}
#: Entities whose status is forced to "suspend" on create.
#:
#: Keywords are deliberately NOT here. Spending is gated by the campaign, group
#: and ad above them, all of which this proxy creates paused - a keyword cannot
#: spend anything on its own. Forcing keywords paused as well would only create
#: a trap: you activate the campaign in the Sklik UI, nothing happens, and the
#: reason is a fourth paused level nobody expected. Callers may still send
#: status explicitly.
_FORCE_PAUSED_ON_CREATE = ("campaigns", "groups", "ads")
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: campaigns/groups/ads are always created paused.
if entity in _FORCE_PAUSED_ON_CREATE:
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
else:
status = clean.get("status")
if status is not None and status not in _VALID_STATUSES:
raise UpstreamError(
f"Item {index}: status must be one of "
+ ", ".join(_VALID_STATUSES)
+ f" (got {status!r}).",
status=400,
)
# Rule 2: money ceilings - only where one is configured (0 = disabled).
_check_money(entity, index, clean)
prepared.append(clean)
if entity in _FORCE_PAUSED_ON_CREATE:
logger.info(
"Prepared %d %s for creation (all forced to status=%s).",
len(prepared),
entity,
PAUSED_STATUS,
)
else:
logger.info("Prepared %d %s for creation.", len(prepared), entity)
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,
"createdPausedEntities": list(_FORCE_PAUSED_ON_CREATE),
"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."
),
}