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