"""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." ), }