rozsireni o keywords a uprava swaggeru
This commit is contained in:
@@ -30,11 +30,11 @@ Interactive docs (Swagger UI): `/docs` — publicly `https://services.csbot.cz/a
|
|||||||
| Google Ads | GET | `/googleads/customers:listAccessibleCustomers` |
|
| Google Ads | GET | `/googleads/customers:listAccessibleCustomers` |
|
||||||
| Sklik | POST | `/sklik/login`, `/sklik/report/{entity}`, `/sklik/rpc/{method}` |
|
| Sklik | POST | `/sklik/login`, `/sklik/report/{entity}`, `/sklik/rpc/{method}` |
|
||||||
| Sklik | GET | `/sklik/limits`, `/sklik/write-limits` |
|
| Sklik | GET | `/sklik/limits`, `/sklik/write-limits` |
|
||||||
| Sklik read | GET | `/sklik/campaigns`, `/sklik/groups`, `/sklik/ads` |
|
| Sklik read | GET | `/sklik/campaigns`, `/sklik/groups`, `/sklik/ads`, `/sklik/keywords` |
|
||||||
| Sklik **write** | POST | `/sklik/{campaigns\|groups\|ads}` — create, always **paused** |
|
| Sklik **write** | POST | `/sklik/{campaigns\|groups\|ads\|keywords}` — create (campaigns/groups/ads always **paused**) |
|
||||||
| Sklik **write** | PUT | `/sklik/{campaigns\|groups\|ads}` — update by id (partial) |
|
| Sklik **write** | PUT | `/sklik/{campaigns\|groups\|ads\|keywords}` — update by id (partial) |
|
||||||
| Sklik **write** | DELETE | `/sklik/{campaigns\|groups\|ads}?ids=` — remove (reversible) |
|
| Sklik **write** | DELETE | `/sklik/{campaigns\|groups\|ads\|keywords}?ids=` — remove (reversible) |
|
||||||
| Sklik **write** | POST | `/sklik/{campaigns\|groups\|ads}/restore?ids=` |
|
| Sklik **write** | POST | `/sklik/{campaigns\|groups\|ads\|keywords}/restore?ids=` |
|
||||||
|
|
||||||
## Credentials (headers)
|
## Credentials (headers)
|
||||||
|
|
||||||
|
|||||||
+12
-7
@@ -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
|
[dokumentace Skliku](https://api.sklik.cz/drak/campaigns.create.html); dávka je
|
||||||
all-or-nothing.
|
all-or-nothing.
|
||||||
|
|
||||||
|
Entita = `campaigns`, `groups`, `ads` nebo `keywords`.
|
||||||
|
|
||||||
| Operace | Endpoint |
|
| Operace | Endpoint |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Založení | `POST /sklik/{campaigns\\|groups\\|ads}` |
|
| Založení | `POST /sklik/{entita}` |
|
||||||
| Úprava | `PUT /sklik/{campaigns\\|groups\\|ads}` (povinné `id`) |
|
| Úprava | `PUT /sklik/{entita}` (povinné `id`) |
|
||||||
| Smazání | `DELETE /sklik/{campaigns\\|groups\\|ads}?ids=1,2` |
|
| Smazání | `DELETE /sklik/{entita}?ids=1,2` |
|
||||||
| Obnovení | `POST /sklik/{campaigns\\|groups\\|ads}/restore?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
|
- **Kampaně, sestavy a inzeráty se zakládají pauznuté.** Sklik má výchozí
|
||||||
neuvedený stav by znamenal živou kampaň – proxy proto při zakládání vždy
|
`status: active`, takže neuvedený stav by znamenal živou kampaň – proxy proto
|
||||||
vynutí `status: "suspend"` a jinou hodnotu ignoruje.
|
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
|
- **`PUT` status nevynucuje** – tím se kampaň pozastavuje (`suspend`) i
|
||||||
spouští (`active`). Pokud má aktivace zůstat výhradně ruční, nastavte
|
spouští (`active`). Pokud má aktivace zůstat výhradně ruční, nastavte
|
||||||
`SKLIK_BLOCK_ACTIVATION=true`; pak je `active` odmítnuto s 403.
|
`SKLIK_BLOCK_ACTIVATION=true`; pak je `active` odmítnuto s 403.
|
||||||
|
|||||||
+143
-26
@@ -30,6 +30,17 @@ from ..clients.sklik_client import SklikClient
|
|||||||
from ..credentials import get_sklik_token
|
from ..credentials import get_sklik_token
|
||||||
from ..errors import UpstreamError
|
from ..errors import UpstreamError
|
||||||
from ..logging_config import get_logger
|
from ..logging_config import get_logger
|
||||||
|
from ..sklik_models import (
|
||||||
|
AdCreate,
|
||||||
|
AdUpdate,
|
||||||
|
CampaignCreate,
|
||||||
|
CampaignUpdate,
|
||||||
|
GroupCreate,
|
||||||
|
GroupUpdate,
|
||||||
|
KeywordCreate,
|
||||||
|
KeywordUpdate,
|
||||||
|
SklikStruct,
|
||||||
|
)
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -80,6 +91,11 @@ _DEFAULT_COLUMNS = {
|
|||||||
"description", "description2", "finalUrl", "group.id", "group.name",
|
"description", "description2", "finalUrl", "group.id", "group.name",
|
||||||
"campaign.id", "campaign.name", "createDate", "deleted",
|
"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 = [
|
_CAMPAIGN_CREATE_EXAMPLE = [
|
||||||
@@ -110,6 +126,12 @@ _CAMPAIGN_UPDATE_EXAMPLE = [{"id": 123456, "dayBudget": 30000, "status": "suspen
|
|||||||
_GROUP_UPDATE_EXAMPLE = [{"id": 654321, "cpc": 450}]
|
_GROUP_UPDATE_EXAMPLE = [{"id": 654321, "cpc": 450}]
|
||||||
_AD_UPDATE_EXAMPLE = [{"id": 987654, "status": "suspend"}]
|
_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(
|
def _optional_user_id(
|
||||||
x_sklik_user_id: str | None = Header(
|
x_sklik_user_id: str | None = Header(
|
||||||
@@ -204,14 +226,19 @@ async def _mutate(
|
|||||||
return payload
|
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(
|
async def _create(
|
||||||
entity: str,
|
entity: str,
|
||||||
items: list[Any],
|
items: list[SklikStruct],
|
||||||
token: str,
|
token: str,
|
||||||
user_id: int | None,
|
user_id: int | None,
|
||||||
idem_key: str | None,
|
idem_key: str | None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
prepared = sklik_guards.prepare_for_create(entity, items)
|
prepared = sklik_guards.prepare_for_create(entity, _structs(items))
|
||||||
return await _mutate(
|
return await _mutate(
|
||||||
f"{entity}.create",
|
f"{entity}.create",
|
||||||
[prepared],
|
[prepared],
|
||||||
@@ -231,12 +258,12 @@ async def _create(
|
|||||||
|
|
||||||
async def _update(
|
async def _update(
|
||||||
entity: str,
|
entity: str,
|
||||||
items: list[Any],
|
items: list[SklikStruct],
|
||||||
token: str,
|
token: str,
|
||||||
user_id: int | None,
|
user_id: int | None,
|
||||||
idem_key: str | None,
|
idem_key: str | None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
prepared = sklik_guards.prepare_for_update(entity, items)
|
prepared = sklik_guards.prepare_for_update(entity, _structs(items))
|
||||||
return await _mutate(
|
return await _mutate(
|
||||||
f"{entity}.update",
|
f"{entity}.update",
|
||||||
[prepared],
|
[prepared],
|
||||||
@@ -411,6 +438,42 @@ async def list_ads(
|
|||||||
return _strip_session(await client.fetch_list("ads", restriction, columns))
|
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 ---------------------------------------------------------
|
# --- Read: statistics ---------------------------------------------------------
|
||||||
@router.post(
|
@router.post(
|
||||||
"/report/{entity}",
|
"/report/{entity}",
|
||||||
@@ -444,12 +507,13 @@ async def report(
|
|||||||
# --- Write: creation (always paused) ------------------------------------------
|
# --- Write: creation (always paused) ------------------------------------------
|
||||||
@router.post("/campaigns", summary="Create campaigns — always PAUSED")
|
@router.post("/campaigns", summary="Create campaigns — always PAUSED")
|
||||||
async def create_campaigns(
|
async def create_campaigns(
|
||||||
body: list[dict[str, Any]] = Body(
|
body: list[CampaignCreate] = Body(
|
||||||
...,
|
...,
|
||||||
examples=[_CAMPAIGN_CREATE_EXAMPLE],
|
examples=[_CAMPAIGN_CREATE_EXAMPLE],
|
||||||
description="Array of campaign structs. 'status' is ignored and forced "
|
description="Campaigns to create. Budgets are in halers (100 = 1 Kc). "
|
||||||
"to 'suspend'. Budgets are in halers (100 = 1 Kc). Required per item: "
|
"'status' cannot be set - creation always forces 'suspend'. Fields not "
|
||||||
"name, type, dayBudget. Sklik batches are all-or-nothing.",
|
"listed in the schema are forwarded to Sklik as-is. Sklik batches are "
|
||||||
|
"all-or-nothing.",
|
||||||
),
|
),
|
||||||
token: str = Depends(get_sklik_token),
|
token: str = Depends(get_sklik_token),
|
||||||
user_id: int | None = Depends(_optional_user_id),
|
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")
|
@router.post("/groups", summary="Create groups/ad sets — always PAUSED")
|
||||||
async def create_groups(
|
async def create_groups(
|
||||||
body: list[dict[str, Any]] = Body(
|
body: list[GroupCreate] = Body(
|
||||||
...,
|
...,
|
||||||
examples=[_GROUP_CREATE_EXAMPLE],
|
examples=[_GROUP_CREATE_EXAMPLE],
|
||||||
description="Array of group structs. 'status' is forced to 'suspend'. "
|
description="Groups to create. cpc is in halers (100 = 1 Kc). 'status' "
|
||||||
"cpc is in halers. Required per item: campaignId, name, cpc.",
|
"cannot be set - creation always forces 'suspend'.",
|
||||||
),
|
),
|
||||||
token: str = Depends(get_sklik_token),
|
token: str = Depends(get_sklik_token),
|
||||||
user_id: int | None = Depends(_optional_user_id),
|
user_id: int | None = Depends(_optional_user_id),
|
||||||
@@ -481,12 +545,12 @@ async def create_groups(
|
|||||||
|
|
||||||
@router.post("/ads", summary="Create ads — always PAUSED")
|
@router.post("/ads", summary="Create ads — always PAUSED")
|
||||||
async def create_ads(
|
async def create_ads(
|
||||||
body: list[dict[str, Any]] = Body(
|
body: list[AdCreate] = Body(
|
||||||
...,
|
...,
|
||||||
examples=[_AD_CREATE_EXAMPLE],
|
examples=[_AD_CREATE_EXAMPLE],
|
||||||
description="Array of ad structs. 'status' is forced to 'suspend'. "
|
description="Ads to create. 'status' cannot be set - creation always "
|
||||||
"Required per item: groupId (plus headline1/headline2/description/"
|
"forces 'suspend'. For the default 'eta' type Sklik also requires "
|
||||||
"finalUrl for the default 'eta' ad type).",
|
"headline1, headline2, description and finalUrl.",
|
||||||
),
|
),
|
||||||
token: str = Depends(get_sklik_token),
|
token: str = Depends(get_sklik_token),
|
||||||
user_id: int | None = Depends(_optional_user_id),
|
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)
|
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 ------------------------------------------------------------
|
# --- Write: update ------------------------------------------------------------
|
||||||
# Update is ordinary CRUD: only the supplied fields change and 'status' is NOT
|
# 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
|
# forced, because changing it is how a campaign is paused or resumed. Set
|
||||||
# SKLIK_BLOCK_ACTIVATION=true to refuse status="active" here as well.
|
# SKLIK_BLOCK_ACTIVATION=true to refuse status="active" here as well.
|
||||||
@router.put("/campaigns", summary="Update campaigns (partial, by id)")
|
@router.put("/campaigns", summary="Update campaigns (partial, by id)")
|
||||||
async def update_campaigns(
|
async def update_campaigns(
|
||||||
body: list[dict[str, Any]] = Body(
|
body: list[CampaignUpdate] = Body(
|
||||||
...,
|
...,
|
||||||
examples=[_CAMPAIGN_UPDATE_EXAMPLE],
|
examples=[_CAMPAIGN_UPDATE_EXAMPLE],
|
||||||
description="Array of campaign structs. 'id' is required per item; every "
|
description="'id' is required per item; every other field is optional "
|
||||||
"other field is optional and only the supplied ones change. 'status' "
|
"and ONLY the fields you send are changed. status='active' resumes the "
|
||||||
"accepts 'active' or 'suspend'. Budgets are in halers. 'type' cannot be "
|
"campaign and spending starts. Budgets are in halers. 'type' cannot be "
|
||||||
"changed.",
|
"changed.",
|
||||||
),
|
),
|
||||||
token: str = Depends(get_sklik_token),
|
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)")
|
@router.put("/groups", summary="Update groups/ad sets (partial, by id)")
|
||||||
async def update_groups(
|
async def update_groups(
|
||||||
body: list[dict[str, Any]] = Body(
|
body: list[GroupUpdate] = Body(
|
||||||
...,
|
...,
|
||||||
examples=[_GROUP_UPDATE_EXAMPLE],
|
examples=[_GROUP_UPDATE_EXAMPLE],
|
||||||
description="Array of group structs. 'id' required; name, status, cpc, "
|
description="'id' required; only the fields you send are changed.",
|
||||||
"cpt, maxUserDailyImpression and devicesPriceRatio are updatable.",
|
|
||||||
),
|
),
|
||||||
token: str = Depends(get_sklik_token),
|
token: str = Depends(get_sklik_token),
|
||||||
user_id: int | None = Depends(_optional_user_id),
|
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)")
|
@router.put("/ads", summary="Update ads (partial, by id)")
|
||||||
async def update_ads(
|
async def update_ads(
|
||||||
body: list[dict[str, Any]] = Body(
|
body: list[AdUpdate] = Body(
|
||||||
...,
|
...,
|
||||||
examples=[_AD_UPDATE_EXAMPLE],
|
examples=[_AD_UPDATE_EXAMPLE],
|
||||||
description="Array of ad structs. 'id' required. NOTE: changing the "
|
description="'id' required; only the fields you send are changed. NOTE: "
|
||||||
"creative (headlines, description, URLs) makes Sklik delete the old ad "
|
"changing the creative (headlines, description, URLs) makes Sklik delete "
|
||||||
"and create a new one with a NEW id - re-read the ad afterwards.",
|
"the old ad and create a new one with a NEW id - re-read it afterwards.",
|
||||||
),
|
),
|
||||||
token: str = Depends(get_sklik_token),
|
token: str = Depends(get_sklik_token),
|
||||||
user_id: int | None = Depends(_optional_user_id),
|
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)
|
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 --------------------------------------------------
|
# --- Write: remove / restore --------------------------------------------------
|
||||||
# Sklik removal is a soft delete ("marked as removed"), so every remove has a
|
# Sklik removal is a soft delete ("marked as removed"), so every remove has a
|
||||||
# matching restore.
|
# matching restore.
|
||||||
@@ -582,6 +679,26 @@ async def remove_ads(
|
|||||||
return await _remove_or_restore("ads", "remove", ids, token, user_id, idem_key)
|
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")
|
@router.post("/campaigns/restore", summary="Restore removed campaigns")
|
||||||
async def restore_campaigns(
|
async def restore_campaigns(
|
||||||
ids: str = Query(..., description="Comma-separated campaign ids to restore."),
|
ids: str = Query(..., description="Comma-separated campaign ids to restore."),
|
||||||
|
|||||||
+27
-1
@@ -45,6 +45,7 @@ _MONEY_FIELDS: dict[str, dict[str, tuple[int, str]]] = {
|
|||||||
"cpt": (config.SKLIK_MAX_CPC_HALERS, "max CPT"),
|
"cpt": (config.SKLIK_MAX_CPC_HALERS, "max CPT"),
|
||||||
},
|
},
|
||||||
"ads": {},
|
"ads": {},
|
||||||
|
"keywords": {"cpc": (config.SKLIK_MAX_CPC_HALERS, "max CPC")},
|
||||||
}
|
}
|
||||||
|
|
||||||
#: Fields Sklik requires on create, checked here so the caller gets a clear
|
#: 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"),
|
"campaigns": ("name", "dayBudget", "type"),
|
||||||
"groups": ("campaignId", "name", "cpc"),
|
"groups": ("campaignId", "name", "cpc"),
|
||||||
"ads": ("groupId",),
|
"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:
|
def _halers_to_czk(value: int) -> str:
|
||||||
return f"{value / 100:.2f} Kc"
|
return f"{value / 100:.2f} Kc"
|
||||||
@@ -117,7 +129,8 @@ def prepare_for_create(entity: str, items: list[Any]) -> list[dict]:
|
|||||||
|
|
||||||
clean = dict(item)
|
clean = dict(item)
|
||||||
|
|
||||||
# Rule 1: always paused, never negotiable.
|
# Rule 1: campaigns/groups/ads are always created paused.
|
||||||
|
if entity in _FORCE_PAUSED_ON_CREATE:
|
||||||
requested = clean.get("status")
|
requested = clean.get("status")
|
||||||
if requested is not None and requested != PAUSED_STATUS:
|
if requested is not None and requested != PAUSED_STATUS:
|
||||||
# Not an error - we simply override it - but it must be visible.
|
# Not an error - we simply override it - but it must be visible.
|
||||||
@@ -130,18 +143,30 @@ def prepare_for_create(entity: str, items: list[Any]) -> list[dict]:
|
|||||||
PAUSED_STATUS,
|
PAUSED_STATUS,
|
||||||
)
|
)
|
||||||
clean["status"] = 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).
|
# Rule 2: money ceilings - only where one is configured (0 = disabled).
|
||||||
_check_money(entity, index, clean)
|
_check_money(entity, index, clean)
|
||||||
|
|
||||||
prepared.append(clean)
|
prepared.append(clean)
|
||||||
|
|
||||||
|
if entity in _FORCE_PAUSED_ON_CREATE:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Prepared %d %s for creation (all forced to status=%s).",
|
"Prepared %d %s for creation (all forced to status=%s).",
|
||||||
len(prepared),
|
len(prepared),
|
||||||
entity,
|
entity,
|
||||||
PAUSED_STATUS,
|
PAUSED_STATUS,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Prepared %d %s for creation.", len(prepared), entity)
|
||||||
return prepared
|
return prepared
|
||||||
|
|
||||||
|
|
||||||
@@ -248,6 +273,7 @@ def budget_limits() -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
"createdStatus": PAUSED_STATUS,
|
"createdStatus": PAUSED_STATUS,
|
||||||
|
"createdPausedEntities": list(_FORCE_PAUSED_ON_CREATE),
|
||||||
"activationBlocked": config.SKLIK_BLOCK_ACTIVATION,
|
"activationBlocked": config.SKLIK_BLOCK_ACTIVATION,
|
||||||
"note": (
|
"note": (
|
||||||
"Everything created through this proxy is paused. null ceilings "
|
"Everything created through this proxy is paused. null ceilings "
|
||||||
|
|||||||
@@ -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."
|
||||||
|
)
|
||||||
@@ -38,6 +38,7 @@ app/
|
|||||||
credentials.py X- header dependencies (GA / GSC / Ads / Sklik)
|
credentials.py X- header dependencies (GA / GSC / Ads / Sklik)
|
||||||
idempotency.py in-memory idempotency store for writes (opt-in per request)
|
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_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/
|
clients/
|
||||||
google.py shared Google client: Bearer/SA token minting + requests
|
google.py shared Google client: Bearer/SA token minting + requests
|
||||||
sklik_client.py Sklik JSON-RPC client (login + session + list/report paging)
|
sklik_client.py Sklik JSON-RPC client (login + session + list/report paging)
|
||||||
|
|||||||
+42
-5
@@ -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/campaigns` | `campaigns.list`, all pages collected. |
|
||||||
| GET | `/sklik/groups` | `groups.list`, filterable by `campaign_ids`. |
|
| GET | `/sklik/groups` | `groups.list`, filterable by `campaign_ids`. |
|
||||||
| GET | `/sklik/ads` | `ads.list`, filterable by `campaign_ids` / `group_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.** |
|
| POST | `/sklik/campaigns` `/sklik/groups` `/sklik/ads` | **Create — always paused.** |
|
||||||
| PUT | `/sklik/campaigns` `/sklik/groups` `/sklik/ads` | **Update** by id (partial). |
|
| PUT | `/sklik/campaigns` `/sklik/groups` `/sklik/ads` | **Update** by id (partial). |
|
||||||
| DELETE | `/sklik/campaigns` `/sklik/groups` `/sklik/ads` | **Remove** (`?ids=1,2,3`) — reversible. |
|
| 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 |
|
| 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 |
|
| 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),
|
documents ([campaigns.create](https://api.sklik.cz/drak/campaigns.create.html),
|
||||||
[campaigns.update](https://api.sklik.cz/drak/campaigns.update.html), and the
|
[campaigns.update](https://api.sklik.cz/drak/campaigns.update.html), and the
|
||||||
`groups.*` / `ads.*` equivalents). Sklik batches are **all-or-nothing**: if one
|
`groups.*` / `ads.*` equivalents). Sklik batches are **all-or-nothing**: if one
|
||||||
item fails, nothing is applied.
|
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
|
Sklik's `status` field **defaults to `active`**, so an omitted status would
|
||||||
create a *live, spending* campaign. The proxy therefore forces
|
create a *live, spending* campaign. The proxy therefore forces
|
||||||
`status: "suspend"` on every created entity and ignores any other value you
|
`status: "suspend"` on every created campaign, group and ad, and ignores any
|
||||||
send (the override is logged).
|
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
|
### 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
|
> one, so the ad gets a **new id**. Re-read the group's ads after such an
|
||||||
> update. Changing only `status` keeps the id.
|
> 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
|
### Remove and restore — reversible
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user