285 lines
11 KiB
Python
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."
|
|
),
|
|
}
|