155 lines
4.9 KiB
Python
155 lines
4.9 KiB
Python
from datetime import datetime
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
|
|
KEY_PATTERN = r"^[a-z0-9_]{3,64}$"
|
|
SESSION_PATTERN = r"^[A-Za-z0-9_-]{8,128}$"
|
|
|
|
|
|
def _validate_metadata(metadata: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
if metadata is None:
|
|
return None
|
|
|
|
if len(metadata) > 20:
|
|
raise ValueError("metadata must contain at most 20 keys")
|
|
|
|
clean: dict[str, Any] = {}
|
|
|
|
for key, value in metadata.items():
|
|
if not isinstance(key, str) or len(key) > 48:
|
|
raise ValueError("metadata keys must be strings up to 48 characters")
|
|
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
clean[key] = value
|
|
continue
|
|
raise ValueError("metadata values must be scalar JSON types")
|
|
|
|
return clean
|
|
|
|
|
|
class ExperimentVariantDefinition(BaseModel):
|
|
variant_key: str = Field(..., pattern=KEY_PATTERN)
|
|
label: str = Field(..., min_length=1, max_length=120)
|
|
allocation: int = Field(..., ge=0, le=100)
|
|
is_control: bool
|
|
|
|
|
|
class ExperimentDefinitionResponse(BaseModel):
|
|
experiment_key: str = Field(..., pattern=KEY_PATTERN)
|
|
cookie_name: str = Field(..., min_length=3, max_length=96)
|
|
name: str
|
|
description: str | None = None
|
|
enabled: bool
|
|
eligible_routes: list[str]
|
|
variants: list[ExperimentVariantDefinition]
|
|
|
|
|
|
class ExperimentDefinitionUpdate(BaseModel):
|
|
cookie_name: str = Field(..., min_length=3, max_length=96)
|
|
name: str = Field(..., min_length=1, max_length=120)
|
|
description: str | None = Field(default=None, max_length=512)
|
|
enabled: bool
|
|
eligible_routes: list[str] = Field(default_factory=list, min_length=1)
|
|
variants: list[ExperimentVariantDefinition] = Field(..., min_length=2)
|
|
|
|
@field_validator("cookie_name")
|
|
@classmethod
|
|
def validate_cookie_name(cls, value: str) -> str:
|
|
if not value.startswith("exp_"):
|
|
raise ValueError("cookie_name must start with 'exp_'")
|
|
return value
|
|
|
|
@field_validator("eligible_routes")
|
|
@classmethod
|
|
def validate_routes(cls, value: list[str]) -> list[str]:
|
|
normalized: list[str] = []
|
|
for route in value:
|
|
if not route.startswith("/"):
|
|
raise ValueError("eligible routes must start with '/'")
|
|
normalized.append(route.rstrip("/") or "/")
|
|
return normalized
|
|
|
|
@field_validator("variants")
|
|
@classmethod
|
|
def validate_variants(cls, value: list[ExperimentVariantDefinition]) -> list[ExperimentVariantDefinition]:
|
|
if sum(1 for item in value if item.is_control) != 1:
|
|
raise ValueError("exactly one control variant is required")
|
|
if sum(item.allocation for item in value) <= 0:
|
|
raise ValueError("variant allocation total must be greater than zero")
|
|
return value
|
|
|
|
|
|
class ExperimentEventBase(BaseModel):
|
|
experiment_key: str = Field(..., pattern=KEY_PATTERN)
|
|
variant_key: str = Field(..., pattern=KEY_PATTERN)
|
|
session_id: str = Field(..., pattern=SESSION_PATTERN)
|
|
user_id: str | None = Field(None, max_length=64)
|
|
path: str = Field(..., min_length=1, max_length=255)
|
|
timestamp: datetime
|
|
metadata: dict[str, Any] | None = None
|
|
|
|
@field_validator("path")
|
|
@classmethod
|
|
def validate_path(cls, value: str) -> str:
|
|
if not value.startswith("/"):
|
|
raise ValueError("path must start with '/'")
|
|
return value
|
|
|
|
@field_validator("metadata")
|
|
@classmethod
|
|
def validate_metadata(cls, value: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
return _validate_metadata(value)
|
|
|
|
|
|
class ExperimentImpressionCreate(ExperimentEventBase):
|
|
event_name: str = Field(default="impression", pattern=r"^impression$")
|
|
|
|
|
|
class ExperimentEventCreate(ExperimentEventBase):
|
|
event_name: str = Field(..., pattern=r"^(cta_click|form_start|form_submit)$")
|
|
|
|
|
|
class ExperimentConversionCreate(ExperimentEventBase):
|
|
event_name: str = Field(default="conversion", pattern=r"^conversion$")
|
|
conversion_value: Decimal | None = Field(default=None, max_digits=12, decimal_places=2)
|
|
|
|
|
|
class ExperimentEventResponse(BaseModel):
|
|
id: UUID
|
|
experiment_key: str
|
|
variant_key: str
|
|
session_id: str
|
|
user_id: str | None = None
|
|
path: str
|
|
event_type: str
|
|
conversion_value: Decimal | None = None
|
|
metadata: dict[str, Any] | None = None
|
|
created_at: datetime
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class ExperimentVariantResult(BaseModel):
|
|
variant_key: str
|
|
impressions: int
|
|
cta_clicks: int
|
|
form_starts: int
|
|
form_submits: int
|
|
conversions: int
|
|
unique_sessions: int
|
|
conversion_rate: float
|
|
conversion_value_total: float
|
|
|
|
|
|
class ExperimentResult(BaseModel):
|
|
experiment_key: str
|
|
generated_at: datetime
|
|
variants: list[ExperimentVariantResult]
|
|
|
|
|
|
class ExperimentIngestResponse(BaseModel):
|
|
ok: bool
|
|
accepted: bool
|