sklik rozsireni na typove metody
This commit is contained in:
@@ -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