diff --git a/README.md b/README.md index 65b15be..6df0e9d 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,11 @@ Interactive docs (Swagger UI): `/docs` — publicly `https://services.csbot.cz/a | Google Ads | GET | `/googleads/customers:listAccessibleCustomers` | | Sklik | POST | `/sklik/login`, `/sklik/report/{entity}`, `/sklik/rpc/{method}` | | Sklik | GET | `/sklik/limits`, `/sklik/write-limits` | -| Sklik read | GET | `/sklik/campaigns`, `/sklik/groups`, `/sklik/ads` | -| Sklik **write** | POST | `/sklik/{campaigns\|groups\|ads}` — create, always **paused** | -| Sklik **write** | PUT | `/sklik/{campaigns\|groups\|ads}` — update by id (partial) | -| Sklik **write** | DELETE | `/sklik/{campaigns\|groups\|ads}?ids=` — remove (reversible) | -| Sklik **write** | POST | `/sklik/{campaigns\|groups\|ads}/restore?ids=` | +| Sklik read | GET | `/sklik/campaigns`, `/sklik/groups`, `/sklik/ads`, `/sklik/keywords` | +| Sklik **write** | POST | `/sklik/{campaigns\|groups\|ads\|keywords}` — create (campaigns/groups/ads always **paused**) | +| Sklik **write** | PUT | `/sklik/{campaigns\|groups\|ads\|keywords}` — update by id (partial) | +| Sklik **write** | DELETE | `/sklik/{campaigns\|groups\|ads\|keywords}?ids=` — remove (reversible) | +| Sklik **write** | POST | `/sklik/{campaigns\|groups\|ads\|keywords}/restore?ids=` | ## Credentials (headers) diff --git a/app/main.py b/app/main.py index 690c543..35dfcd6 100644 --- a/app/main.py +++ b/app/main.py @@ -122,16 +122,21 @@ Plné CRUD nad kampaněmi, sestavami a inzeráty. Tělo je **pole struktur** pod [dokumentace Skliku](https://api.sklik.cz/drak/campaigns.create.html); dávka je all-or-nothing. +Entita = `campaigns`, `groups`, `ads` nebo `keywords`. + | 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` | +| Založení | `POST /sklik/{entita}` | +| Úprava | `PUT /sklik/{entita}` (povinné `id`) | +| Smazání | `DELETE /sklik/{entita}?ids=1,2` | +| Obnovení | `POST /sklik/{entita}/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. +- **Kampaně, sestavy a inzeráty se zakládají pauznuté.** Sklik má výchozí + `status: active`, takže neuvedený stav by znamenal živou kampaň – proxy proto + při zakládání vynutí `status: "suspend"` a jinou hodnotu ignoruje. +- **Klíčová slova jsou výjimka** a pauznutá se nezakládají. Samo o sobě nic + neutratí (utrácení hlídá kampaň/sestava/inzerát nad ním) a pauznuté klíčové + slovo by způsobilo, že po ručním spuštění kampaně se nic nestane. - **`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. diff --git a/app/routers/sklik.py b/app/routers/sklik.py index 7132c7e..23e73be 100644 --- a/app/routers/sklik.py +++ b/app/routers/sklik.py @@ -30,6 +30,17 @@ from ..clients.sklik_client import SklikClient from ..credentials import get_sklik_token from ..errors import UpstreamError from ..logging_config import get_logger +from ..sklik_models import ( + AdCreate, + AdUpdate, + CampaignCreate, + CampaignUpdate, + GroupCreate, + GroupUpdate, + KeywordCreate, + KeywordUpdate, + SklikStruct, +) logger = get_logger(__name__) @@ -80,6 +91,11 @@ _DEFAULT_COLUMNS = { "description", "description2", "finalUrl", "group.id", "group.name", "campaign.id", "campaign.name", "createDate", "deleted", ], + "keywords": [ + "id", "name", "status", "matchType", "cpc", "url", "disabled", + "group.id", "group.name", "campaign.id", "campaign.name", + "createDate", "deleted", + ], } _CAMPAIGN_CREATE_EXAMPLE = [ @@ -110,6 +126,12 @@ _CAMPAIGN_UPDATE_EXAMPLE = [{"id": 123456, "dayBudget": 30000, "status": "suspen _GROUP_UPDATE_EXAMPLE = [{"id": 654321, "cpc": 450}] _AD_UPDATE_EXAMPLE = [{"id": 987654, "status": "suspend"}] +_KEYWORD_CREATE_EXAMPLE = [ + {"groupId": 654321, "name": "levne boty", "matchType": "phrase", "cpc": 300}, + {"groupId": 654321, "name": "damske boty", "matchType": "exact"}, +] +_KEYWORD_UPDATE_EXAMPLE = [{"id": 555001, "cpc": 400}] + def _optional_user_id( x_sklik_user_id: str | None = Header( @@ -204,14 +226,19 @@ async def _mutate( return payload +def _structs(items: list[SklikStruct]) -> list[dict[str, Any]]: + """Model -> Sklik struct, keeping only the fields the caller actually sent.""" + return [item.to_sklik() for item in items] + + async def _create( entity: str, - items: list[Any], + items: list[SklikStruct], token: str, user_id: int | None, idem_key: str | None, ) -> Any: - prepared = sklik_guards.prepare_for_create(entity, items) + prepared = sklik_guards.prepare_for_create(entity, _structs(items)) return await _mutate( f"{entity}.create", [prepared], @@ -231,12 +258,12 @@ async def _create( async def _update( entity: str, - items: list[Any], + items: list[SklikStruct], token: str, user_id: int | None, idem_key: str | None, ) -> Any: - prepared = sklik_guards.prepare_for_update(entity, items) + prepared = sklik_guards.prepare_for_update(entity, _structs(items)) return await _mutate( f"{entity}.update", [prepared], @@ -411,6 +438,42 @@ async def list_ads( return _strip_session(await client.fetch_list("ads", restriction, columns)) +@router.get("/keywords", summary="List keywords (keywords.list)") +async def list_keywords( + campaign_ids: str | None = Query( + None, description="Comma-separated campaign ids to list keywords from." + ), + group_ids: str | None = Query( + None, description="Comma-separated group ids to list keywords from." + ), + ids: str | None = Query(None, description="Comma-separated keyword 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["keywords"] + ) + async with SklikClient(token, user_id=user_id) as client: + return _strip_session(await client.fetch_list("keywords", restriction, columns)) + + # --- Read: statistics --------------------------------------------------------- @router.post( "/report/{entity}", @@ -444,12 +507,13 @@ async def report( # --- Write: creation (always paused) ------------------------------------------ @router.post("/campaigns", summary="Create campaigns — always PAUSED") async def create_campaigns( - body: list[dict[str, Any]] = Body( + body: list[CampaignCreate] = 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.", + description="Campaigns to create. Budgets are in halers (100 = 1 Kc). " + "'status' cannot be set - creation always forces 'suspend'. Fields not " + "listed in the schema are forwarded to Sklik as-is. Sklik batches are " + "all-or-nothing.", ), token: str = Depends(get_sklik_token), user_id: int | None = Depends(_optional_user_id), @@ -466,11 +530,11 @@ async def create_campaigns( @router.post("/groups", summary="Create groups/ad sets — always PAUSED") async def create_groups( - body: list[dict[str, Any]] = Body( + body: list[GroupCreate] = 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.", + description="Groups to create. cpc is in halers (100 = 1 Kc). 'status' " + "cannot be set - creation always forces 'suspend'.", ), token: str = Depends(get_sklik_token), user_id: int | None = Depends(_optional_user_id), @@ -481,12 +545,12 @@ async def create_groups( @router.post("/ads", summary="Create ads — always PAUSED") async def create_ads( - body: list[dict[str, Any]] = Body( + body: list[AdCreate] = 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).", + description="Ads to create. 'status' cannot be set - creation always " + "forces 'suspend'. For the default 'eta' type Sklik also requires " + "headline1, headline2, description and finalUrl.", ), token: str = Depends(get_sklik_token), user_id: int | None = Depends(_optional_user_id), @@ -495,18 +559,36 @@ async def create_ads( return await _create("ads", body, token, user_id, idem_key) +@router.post("/keywords", summary="Create keywords") +async def create_keywords( + body: list[KeywordCreate] = Body( + ..., + examples=[_KEYWORD_CREATE_EXAMPLE], + description="Keywords to create. cpc is in halers (100 = 1 Kc); omit it " + "to use the group's default. Unlike campaigns/groups/ads, 'status' is " + "NOT forced here - a keyword cannot spend anything on its own, since " + "the campaign, group and ad above it are all created paused. Sklik " + "defaults it to 'active'. Returns positiveKeywordIds.", + ), + 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("keywords", 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( + body: list[CampaignUpdate] = 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 " + description="'id' is required per item; every other field is optional " + "and ONLY the fields you send are changed. status='active' resumes the " + "campaign and spending starts. Budgets are in halers. 'type' cannot be " "changed.", ), token: str = Depends(get_sklik_token), @@ -518,11 +600,10 @@ async def update_campaigns( @router.put("/groups", summary="Update groups/ad sets (partial, by id)") async def update_groups( - body: list[dict[str, Any]] = Body( + body: list[GroupUpdate] = Body( ..., examples=[_GROUP_UPDATE_EXAMPLE], - description="Array of group structs. 'id' required; name, status, cpc, " - "cpt, maxUserDailyImpression and devicesPriceRatio are updatable.", + description="'id' required; only the fields you send are changed.", ), token: str = Depends(get_sklik_token), user_id: int | None = Depends(_optional_user_id), @@ -533,12 +614,12 @@ async def update_groups( @router.put("/ads", summary="Update ads (partial, by id)") async def update_ads( - body: list[dict[str, Any]] = Body( + body: list[AdUpdate] = 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.", + description="'id' required; only the fields you send are changed. NOTE: " + "changing the creative (headlines, description, URLs) makes Sklik delete " + "the old ad and create a new one with a NEW id - re-read it afterwards.", ), token: str = Depends(get_sklik_token), user_id: int | None = Depends(_optional_user_id), @@ -547,6 +628,22 @@ async def update_ads( return await _update("ads", body, token, user_id, idem_key) +@router.put("/keywords", summary="Update keywords (partial, by id)") +async def update_keywords( + body: list[KeywordUpdate] = Body( + ..., + examples=[_KEYWORD_UPDATE_EXAMPLE], + description="'id' required; only cpc, url and status can be changed. " + "The keyword text and matchType are immutable in Sklik - remove the " + "keyword and create a new one instead.", + ), + 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("keywords", 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. @@ -582,6 +679,26 @@ async def remove_ads( return await _remove_or_restore("ads", "remove", ids, token, user_id, idem_key) +@router.delete("/keywords", summary="Remove keywords (reversible)") +async def remove_keywords( + ids: str = Query(..., description="Comma-separated keyword 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("keywords", "remove", ids, token, user_id, idem_key) + + +@router.post("/keywords/restore", summary="Restore removed keywords") +async def restore_keywords( + ids: str = Query(..., description="Comma-separated keyword 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("keywords", "restore", 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."), diff --git a/app/sklik_guards.py b/app/sklik_guards.py index 6cb10b1..8901b3b 100644 --- a/app/sklik_guards.py +++ b/app/sklik_guards.py @@ -45,6 +45,7 @@ _MONEY_FIELDS: dict[str, dict[str, tuple[int, str]]] = { "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 @@ -53,8 +54,19 @@ _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" @@ -117,31 +129,44 @@ def prepare_for_create(entity: str, items: list[Any]) -> list[dict]: 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 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) - logger.info( - "Prepared %d %s for creation (all forced to status=%s).", - len(prepared), - entity, - PAUSED_STATUS, - ) + 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 @@ -248,6 +273,7 @@ def budget_limits() -> dict[str, Any]: ) ), "createdStatus": PAUSED_STATUS, + "createdPausedEntities": list(_FORCE_PAUSED_ON_CREATE), "activationBlocked": config.SKLIK_BLOCK_ACTIVATION, "note": ( "Everything created through this proxy is paused. null ceilings " diff --git a/app/sklik_models.py b/app/sklik_models.py new file mode 100644 index 0000000..dcd3bc0 --- /dev/null +++ b/app/sklik_models.py @@ -0,0 +1,360 @@ +"""Request models for the Sklik write endpoints. + +These exist for **discoverability**: without them Swagger shows the create/update +bodies as a bare "array of objects" and nobody can tell what a campaign struct +needs. Since these endpoints spend money, the fields, units and allowed values +have to be visible in the UI. + +Two rules make the models safe as a passthrough: + + * ``extra="allow"`` - every model accepts fields it does not declare, so the + less common parts of the Sklik API (regions, schedule, premise, retargeting + settings, anything added upstream later) keep working without a code change. + The declared fields document the common path; they do not fence it in. + * ``exclude_unset`` when dumping - only the fields the caller actually sent are + forwarded. This is what makes ``update`` a genuine partial update instead of + silently resetting every omitted field to a default. + +Field names match the Sklik API exactly (camelCase), so what you see in Swagger +is what goes on the wire. Monetary amounts are in **halers** (100 = 1 Kc), the +unit the Sklik API itself uses. + +Reference: https://api.sklik.cz/drak/ +""" +from __future__ import annotations + +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class SklikStruct(BaseModel): + """Base: unknown fields are forwarded upstream untouched.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + def to_sklik(self) -> dict[str, Any]: + """Only the fields the caller actually set (declared or extra). + + ``mode="json"`` so enums become plain strings and nested structs plain + dicts - exactly what goes on the wire. + """ + return self.model_dump(mode="json", exclude_unset=True, by_alias=True) + + +# --- enums -------------------------------------------------------------------- +class Status(str, Enum): + active = "active" + suspend = "suspend" + + +class CampaignType(str, Enum): + fulltext = "fulltext" + context = "context" + product = "product" + video = "video" + simple = "simple" + zbozi = "zbozi" + + +class AdSelection(str, Enum): + weighted = "weighted" + random = "random" + cpa = "cpa" + cos = "cos" + + +class PaymentMethod(str, Enum): + cpc = "cpc" + cpm = "cpm" + + +class VideoFormat(str, Enum): + both = "both" + instream = "instream" + outstream = "outstream" + + +class AdType(str, Enum): + eta = "eta" + combined = "combined" + branding = "branding" + dynamicBanner = "dynamicBanner" + + +class PremiseMode(str, Enum): + disabled = "disabled" + one = "one" + nearest = "nearest" + inherit = "inherit" + + +# --- shared sub-structs ------------------------------------------------------- +class DevicesPriceRatio(SklikStruct): + """Per-device CPC/CPT modifier, in whole percent relative to the base bid.""" + + desktop: int | None = None + mobile: int | None = None + tablet: int | None = None + other: int | None = None + + +class NegativeKeyword(SklikStruct): + name: str + matchType: str | None = Field( + default=None, description="Match type, e.g. 'broad', 'phrase', 'exact'." + ) + + +# --- campaigns ---------------------------------------------------------------- +class CampaignCreate(SklikStruct): + """A campaign to create. + + ``status`` is deliberately absent: creation always forces ``suspend``. + """ + + name: str = Field(description="Campaign name.") + type: CampaignType = Field( + description="Campaign type. Cannot be changed later." + ) + dayBudget: int = Field( + description="Daily budget in HALERS (100 = 1 Kc). 20000 = 200 Kc/day.", + ge=0, + ) + totalBudget: int | None = Field( + default=None, description="Total budget in halers, or null for none." + ) + totalClicks: int | None = Field( + default=None, description="Total click limit, or null for none." + ) + adSelection: AdSelection | None = Field( + default=None, description="How ads are rotated within a group." + ) + startDate: str | None = Field( + default=None, description="Start date, e.g. '2026-08-01'." + ) + endDate: str | None = Field(default=None, description="End date.") + paymentMethod: PaymentMethod | None = Field( + default=None, description="cpc, or cpm for context campaigns." + ) + videoFormat: VideoFormat | None = Field( + default=None, description="Video campaigns only." + ) + excludedSearchServices: list[Any] | None = Field( + default=None, description="Search service ids to exclude." + ) + excludedUrls: list[str] | None = Field( + default=None, description="Excluded URLs, e.g. 'http://domain.com'." + ) + negativeKeywords: list[NegativeKeyword] | None = None + regions: list[Any] | None = Field( + default=None, description="Geotargeting region ids." + ) + schedule: list[Any] | None = Field( + default=None, description="7-day spending schedule (Mon-Sun)." + ) + premise: dict[str, Any] | None = Field( + default=None, description="Firmy.cz connection settings." + ) + devicesPriceRatio: DevicesPriceRatio | None = None + + +class CampaignUpdate(SklikStruct): + """Fields to change on one campaign. Only what you send is changed. + + ``type`` is missing on purpose - Sklik cannot change a campaign's type. + """ + + id: int = Field(description="Id of the campaign to update. Required.") + name: str | None = None + status: Status | None = Field( + default=None, + description="'suspend' pauses the campaign, 'active' resumes it and " + "spending starts. Omit to leave the current state untouched.", + ) + dayBudget: int | None = Field( + default=None, description="Daily budget in HALERS (100 = 1 Kc).", ge=0 + ) + totalBudget: int | None = Field(default=None, description="In halers, or null.") + totalClicks: int | None = None + sharedBudgetId: int | None = None + resetExhaustedTotalBudget: bool | None = None + resetExhaustedTotalClicks: bool | None = None + adSelection: AdSelection | None = None + startDate: str | None = None + endDate: str | None = None + paymentMethod: PaymentMethod | None = None + videoFormat: VideoFormat | None = None + zboziBiddingType: str | None = None + excludedSearchServices: list[Any] | None = None + excludedUrls: list[str] | None = None + negativeKeywords: list[NegativeKeyword] | None = None + regions: list[Any] | None = None + schedule: list[Any] | None = None + premise: dict[str, Any] | None = None + devicesPriceRatio: DevicesPriceRatio | None = None + + +# --- groups ------------------------------------------------------------------- +class GroupCreate(SklikStruct): + """A group (ad set) to create. ``status`` is forced to ``suspend``.""" + + campaignId: int = Field(description="Campaign this group belongs to.") + name: str = Field(description="Group name.") + cpc: int = Field( + description="Default max cost per click in HALERS (100 = 1 Kc). " + "300 = 3 Kc.", + ge=0, + ) + cpt: int | None = Field( + default=None, description="Cost per thousand impressions, in halers." + ) + maxUserDailyImpression: int | None = Field( + default=None, description="Max impressions per user per day." + ) + devicesPriceRatio: DevicesPriceRatio | None = None + + +class GroupUpdate(SklikStruct): + id: int = Field(description="Id of the group to update. Required.") + name: str | None = None + status: Status | None = Field( + default=None, description="'suspend' pauses, 'active' resumes." + ) + cpc: int | None = Field(default=None, description="Max CPC in halers.", ge=0) + cpt: int | None = Field(default=None, description="CPT in halers.", ge=0) + maxUserDailyImpression: int | None = None + devicesPriceRatio: DevicesPriceRatio | None = None + + +# --- ads ---------------------------------------------------------------------- +class _AdFields(SklikStruct): + """Fields shared by ad create and update.""" + + adType: AdType | None = Field( + default=None, description="Defaults to 'eta' (expanded text ad)." + ) + name: str | None = Field( + default=None, description="Required for 'branding' ads." + ) + headline1: str | None = Field(default=None, description="Required for 'eta'.") + headline2: str | None = Field(default=None, description="Required for 'eta'.") + headline3: str | None = Field(default=None, description="'eta' only.") + path1: str | None = Field(default=None, description="'eta' only.") + path2: str | None = Field(default=None, description="'eta' only; needs path1.") + description: str | None = Field( + default=None, description="Required for 'eta' and 'combined'." + ) + description2: str | None = Field(default=None, description="'eta' only.") + finalUrl: str | None = Field( + default=None, description="Landing page. Required for eta/combined/branding." + ) + mobileFinalUrl: str | None = None + trackingTemplate: str | None = None + impressionTrackingTemplate: str | None = None + impressionTrackingTemplate2: str | None = None + longLine: str | None = Field(default=None, description="Required for 'combined'.") + shortLine: str | None = Field(default=None, description="Required for 'combined'.") + companyName: str | None = Field( + default=None, description="Required for 'combined'." + ) + colorAccent: str | None = Field( + default=None, description="Hex without '#'. 'combined' only." + ) + colorMain: str | None = Field( + default=None, description="Hex without '#'. 'combined' only." + ) + imageId: int | None = None + imageLogoId: int | None = None + imageSquareId: int | None = None + imageLandscapeLogoId: int | None = None + premiseMode: PremiseMode | None = None + premiseId: int | None = Field( + default=None, description="Only with premiseMode='one'." + ) + dynamicTemplateId: int | None = Field( + default=None, description="'dynamicBanner' only." + ) + schedule: list[Any] | None = Field( + default=None, description="7-day schedule (Mon-Sun), or null." + ) + + +class AdCreate(_AdFields): + """An ad to create. ``status`` is forced to ``suspend``.""" + + groupId: int = Field(description="Group the ad is placed in.") + requestId: int | None = Field( + default=None, description="Echoed back in diagnostics to match items up." + ) + + +class MatchType(str, Enum): + broad = "broad" + phrase = "phrase" + exact = "exact" + + +# --- keywords ----------------------------------------------------------------- +class KeywordCreate(SklikStruct): + """A keyword to create. + + Unlike campaigns/groups/ads, ``status`` IS settable here - see + ``app.sklik_guards`` for why. + """ + + name: str = Field(description="The keyword text.") + groupId: int = Field(description="Group the keyword is created in.") + matchType: MatchType | None = Field( + default=None, + description="Match type; Sklik defaults to 'broad'. Cannot be changed " + "later - to change it, remove the keyword and create a new one.", + ) + cpc: int | None = Field( + default=None, + description="Max cost per click in HALERS (100 = 1 Kc), or null to use " + "the group's default.", + ge=0, + ) + url: str | None = Field( + default=None, description="Target URL, or null for the ad's URL." + ) + status: Status | None = Field( + default=None, + description="Sklik defaults to 'active'. A keyword in a paused campaign " + "cannot spend anything, so this is left to you.", + ) + + +class KeywordUpdate(SklikStruct): + """Fields to change on one keyword. + + ``name`` and ``matchType`` are **not** updatable in Sklik - to change either, + remove the keyword and create a new one. + """ + + id: int = Field(description="Id of the keyword to update. Required.") + cpc: int | None = Field( + default=None, + description="Max CPC in halers, or null to fall back to the group's.", + ge=0, + ) + url: str | None = Field(default=None, description="Target URL, or null to unset.") + status: Status | None = Field( + default=None, description="'suspend' pauses the keyword, 'active' resumes." + ) + + +class AdUpdate(_AdFields): + """Fields to change on one ad. + + Sklik cannot edit an existing ad's creative: changing headlines, description + or URLs makes it **delete the old ad and create a new one with a new id**. + Changing only ``status`` keeps the id. + """ + + id: int = Field(description="Id of the ad to update. Required.") + status: Status | None = Field( + default=None, description="'suspend' pauses, 'active' resumes." + ) diff --git a/documentation/overview.md b/documentation/overview.md index 3e85044..ba2fa9c 100644 --- a/documentation/overview.md +++ b/documentation/overview.md @@ -38,6 +38,7 @@ app/ credentials.py X- header dependencies (GA / GSC / Ads / Sklik) idempotency.py in-memory idempotency store for writes (opt-in per request) sklik_guards.py write guard rails: forced paused status, optional budget caps + sklik_models.py typed request bodies for Sklik writes (Swagger schema; extra fields pass through) clients/ google.py shared Google client: Bearer/SA token minting + requests sklik_client.py Sklik JSON-RPC client (login + session + list/report paging) diff --git a/documentation/sklik.md b/documentation/sklik.md index e6a30f2..f458759 100644 --- a/documentation/sklik.md +++ b/documentation/sklik.md @@ -49,6 +49,7 @@ access denied, bad arguments) are surfaced as `upstream_error` with the Sklik | GET | `/sklik/campaigns` | `campaigns.list`, all pages collected. | | GET | `/sklik/groups` | `groups.list`, filterable by `campaign_ids`. | | GET | `/sklik/ads` | `ads.list`, filterable by `campaign_ids` / `group_ids`. | +| GET | `/sklik/keywords` | `keywords.list`, filterable by `campaign_ids` / `group_ids`. | | POST | `/sklik/campaigns` `/sklik/groups` `/sklik/ads` | **Create — always paused.** | | PUT | `/sklik/campaigns` `/sklik/groups` `/sklik/ads` | **Update** by id (partial). | | DELETE | `/sklik/campaigns` `/sklik/groups` `/sklik/ads` | **Remove** (`?ids=1,2,3`) — reversible. | @@ -94,18 +95,48 @@ Query parameters: | Remove | `DELETE /sklik/{entity}?ids=1,2` | `{entity}.remove` | ids in the query | | Restore | `POST /sklik/{entity}/restore?ids=1,2` | `{entity}.restore` | ids in the query | -`{entity}` ∈ `campaigns`, `groups`, `ads`. The structs are exactly the ones Sklik +`{entity}` ∈ `campaigns`, `groups`, `ads`, `keywords`. The structs are exactly the ones Sklik documents ([campaigns.create](https://api.sklik.cz/drak/campaigns.create.html), [campaigns.update](https://api.sklik.cz/drak/campaigns.update.html), and the `groups.*` / `ads.*` equivalents). Sklik batches are **all-or-nothing**: if one item fails, nothing is applied. -### Create — everything is paused, not configurable +### The bodies are typed in Swagger + +Create and update bodies are modelled (`app/sklik_models.py`), so `/docs` shows +every field with its type, whether it is required, the allowed enum values +(campaign `type`, `status`, `adType`, `adSelection`, `paymentMethod`, +`premiseMode`, `videoFormat`) and a description with the unit. "Try it out" is +therefore fillable without reading the Sklik docs first. + +Two properties of the models matter: + +- **They do not restrict you.** Every model accepts undeclared fields and + forwards them to Sklik untouched, so the less common parts of the API + (retargeting, product sets, anything Sklik adds later) keep working without a + code change here. The schema documents the common path; it is not a whitelist. +- **Update is genuinely partial.** Only the fields you actually send are + forwarded, so omitting a field leaves it alone rather than resetting it. An + explicit `null` is still sent (that is how you clear `totalBudget`). + +Schema violations (missing required field, bad enum value) come back as **422** +with the offending field in `detail[].loc` — before any Sklik call. The business +guard rails (budget ceilings, blocked activation) return **400** / **403**. + +### Create — campaigns, groups and ads are paused, not configurable Sklik's `status` field **defaults to `active`**, so an omitted status would create a *live, spending* campaign. The proxy therefore forces -`status: "suspend"` on every created entity and ignores any other value you -send (the override is logged). +`status: "suspend"` on every created campaign, group and ad, and ignores any +other value you send (the override is logged). + +> **Keywords are the exception** and are *not* forced. A keyword cannot spend +> anything on its own — the campaign, group and ad above it are all created +> paused, and those gate the spending. Forcing keywords paused as well would +> only create a trap: you activate the campaign in the Sklik UI, nothing +> happens, and the cause is a fourth paused level nobody expected. Sklik's own +> default (`active`) applies; send `status` explicitly if you want otherwise. +> `GET /sklik/write-limits` lists which entities are force-paused. ### Update — ordinary CRUD, status is not forced @@ -125,7 +156,13 @@ way. > one, so the ad gets a **new id**. Re-read the group's ads after such an > update. Changing only `status` keeps the id. -`type` cannot be changed on a campaign. +Immutable fields, so `PUT` does not offer them at all: + +| Entity | Cannot be changed | +| --- | --- | +| campaign | `type` | +| keyword | `name`, `matchType` — remove it and create a new one | +| ad | the creative (see the note above) | ### Remove and restore — reversible