Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions examples/v3_reference_seller/src/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
and storage**. Adopters fork this file and extend the columns with
their own seller-side audit / contract / billing fields.

Four tables make up the spine:
Five tables make up the spine:

* :class:`Tenant` — multi-tenant root. The
:class:`adcp.server.SubdomainTenantMiddleware` resolves
Expand All @@ -20,6 +20,9 @@
delivery target).
* :class:`MediaBuy` — terminal artifact of ``create_media_buy``.
Idempotency-keyed for replay safety.
* :class:`PerformanceFeedback` — buyer-supplied performance signals
for a media buy. Idempotency-keyed; persisted by
``provide_performance_feedback``.

Admin API and protocol-side audit log live in separate tables
(:mod:`audit` ships :class:`AuditEvent`).
Expand Down Expand Up @@ -344,4 +347,57 @@ class MediaBuy(Base):
)


__all__ = ["Account", "Base", "BuyerAgent", "MediaBuy", "Tenant"]
# ---------------------------------------------------------------------------
# PerformanceFeedback — buyer-supplied performance signals
# ---------------------------------------------------------------------------


class PerformanceFeedback(Base):
"""Buyer-supplied performance signals for a media buy.

Persisted by ``provide_performance_feedback``. Idempotency-keyed
per tenant — mirrors the :class:`MediaBuy` replay-safety pattern.
Adopters extend with campaign-level aggregation columns or FK to
their internal attribution tables.
"""

__tablename__ = "performance_feedback"

id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)

tenant_id: Mapped[str] = mapped_column(
String(64), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
account_id: Mapped[str] = mapped_column(
String(64), ForeignKey("accounts.id", ondelete="RESTRICT"), nullable=False
)

#: Wire ``media_buy_id`` the feedback is attached to.
media_buy_id: Mapped[str] = mapped_column(String(64), nullable=False)

#: Buyer's idempotency key — prevents double-counting the same
#: feedback event on retry.
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)

performance_index: Mapped[float | None] = mapped_column(Float, nullable=True)
measurement_period: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
metric_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
package_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
creative_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
feedback_source: Mapped[str | None] = mapped_column(String(64), nullable=True)

received_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_utcnow
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_utcnow
)

__table_args__ = (
UniqueConstraint("tenant_id", "idempotency_key", name="perf_feedback_idem_uk"),
Index("perf_feedback_tenant_idx", "tenant_id"),
Index("perf_feedback_media_buy_idx", "media_buy_id"),
)


__all__ = ["Account", "Base", "BuyerAgent", "MediaBuy", "PerformanceFeedback", "Tenant"]
149 changes: 147 additions & 2 deletions examples/v3_reference_seller/src/platform.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
"""DecisioningPlatform impl for the v3 reference seller.

Sales-non-guaranteed specialism with the five required Sales methods:
Sales-non-guaranteed specialism with all nine Sales methods:

* :meth:`get_products` — read inventory catalog
* :meth:`create_media_buy` — terminal artifact insert; idempotency-keyed
* :meth:`update_media_buy` — patch (status / pause / spend cap)
* :meth:`sync_creatives` — accept creative manifests
* :meth:`get_media_buy_delivery` — read delivery actuals
* :meth:`get_media_buys` — list media buys for the dispatching principal
* :meth:`provide_performance_feedback` — accept buyer-side perf signals
* :meth:`list_creative_formats` — return supported format catalog (stub)
* :meth:`list_creatives` — list buyer-uploaded creatives (stub)

All five run against the SQLAlchemy models in :mod:`models`. The
All six run against the SQLAlchemy models in :mod:`models`. The
platform reads the resolved :class:`adcp.decisioning.BuyerAgent`
from :attr:`RequestContext.buyer_agent` (set by the framework's
dispatch gate) and the :class:`adcp.decisioning.Account` from
Expand Down Expand Up @@ -43,9 +47,17 @@
CreateMediaBuySuccessResponse,
GetMediaBuyDeliveryRequest,
GetMediaBuyDeliveryResponse,
GetMediaBuysRequest,
GetMediaBuysResponse,
GetProductsRequest,
GetProductsResponse,
ListCreativeFormatsRequest,
ListCreativeFormatsResponse,
ListCreativesRequest,
ListCreativesResponse,
Product,
ProvidePerformanceFeedbackRequest,
ProvidePerformanceFeedbackSuccessResponse,
SyncCreativesRequest,
SyncCreativesSuccessResponse,
UpdateMediaBuyRequest,
Expand All @@ -59,6 +71,7 @@

from .models import Account as AccountRow
from .models import MediaBuy as MediaBuyRow
from .models import PerformanceFeedback as PerformanceFeedbackRow

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -311,6 +324,138 @@ async def get_media_buy_delivery(
del req, ctx
return GetMediaBuyDeliveryResponse(media_buys=[])

# ----- get_media_buys --------------------------------------------------

async def get_media_buys(
self, req: GetMediaBuysRequest, ctx: RequestContext
) -> GetMediaBuysResponse:
"""List media buys for the dispatching principal.

Queries the existing :class:`MediaBuyRow` table filtered by
tenant + account, with optional ``media_buy_ids`` and
``status_filter`` narrowing. Rows missing the wire-required
``currency`` or ``total_budget`` columns (created when the
buyer omitted budget in ``create_media_buy``) are silently
excluded — production adopters should enforce budget at
creation time to avoid silent exclusion here.
"""
if ctx.account is None:
raise AdcpError(
"INTERNAL_ERROR",
message="Dispatch should have populated account.",
recovery="terminal",
)
async with self._sessionmaker() as session:
stmt = select(MediaBuyRow).where(
MediaBuyRow.tenant_id == ctx.account.metadata["tenant_id"],
MediaBuyRow.account_id == ctx.account.id,
)
if req.media_buy_ids:
stmt = stmt.where(MediaBuyRow.media_buy_id.in_(req.media_buy_ids))
if req.status_filter:
stmt = stmt.where(MediaBuyRow.status.in_(req.status_filter))
result = await session.execute(stmt)
rows = result.scalars().all()

wire_buys = []
for row in rows:
if row.total_budget is None or row.currency is None:
logger.warning(
"Skipping media_buy_id=%s from get_media_buys: "
"missing total_budget or currency. Enforce budget at "
"create_media_buy time to avoid silent exclusion.",
row.media_buy_id,
)
continue
wire_buys.append(
{
"media_buy_id": row.media_buy_id,
"status": row.status,
"currency": row.currency,
"total_budget": row.total_budget,
"packages": [],
"start_time": row.start_time,
"end_time": row.end_time,
"created_at": row.created_at,
"updated_at": row.updated_at,
}
)
return GetMediaBuysResponse(media_buys=wire_buys)

# ----- provide_performance_feedback ------------------------------------

async def provide_performance_feedback(
self, req: ProvidePerformanceFeedbackRequest, ctx: RequestContext
) -> ProvidePerformanceFeedbackSuccessResponse:
"""Persist buyer-side performance signals for a media buy.

Idempotency-keyed: a retry with the same ``idempotency_key``
under the same tenant raises an ``IntegrityError`` at the DB
level (``perf_feedback_idem_uk``), which the framework's
idempotency middleware catches and replays the cached response.
"""
if ctx.account is None:
raise AdcpError(
"INTERNAL_ERROR",
message="Dispatch should have populated account.",
recovery="terminal",
)
row = PerformanceFeedbackRow(
tenant_id=ctx.account.metadata["tenant_id"],
account_id=ctx.account.id,
media_buy_id=req.media_buy_id,
idempotency_key=req.idempotency_key,
performance_index=req.performance_index,
measurement_period=req.measurement_period.model_dump(mode="json"),
metric_type=req.metric_type.value if req.metric_type else None,
package_id=req.package_id,
creative_id=req.creative_id,
feedback_source=req.feedback_source.value if req.feedback_source else None,
)
async with self._sessionmaker() as session, session.begin():
session.add(row)
logger.info(
"Persisted performance feedback for media_buy=%s account=%s",
req.media_buy_id,
ctx.account.id,
)
return ProvidePerformanceFeedbackSuccessResponse(success=True)

# ----- list_creative_formats -------------------------------------------

async def list_creative_formats(
self, req: ListCreativeFormatsRequest, ctx: RequestContext
) -> ListCreativeFormatsResponse:
"""Return the seller's supported creative formats.

Stub: returns an empty catalog. Production adopters replace
this with a query against a ``CreativeFormat`` table or a
fetch from their creative management platform. When formats
are tenant-configurable, add a ``creative_formats`` table and
filter by ``ctx.account.metadata['tenant_id']``.
"""
del req, ctx
return ListCreativeFormatsResponse(formats=[])

# ----- list_creatives --------------------------------------------------

async def list_creatives(
self, req: ListCreativesRequest, ctx: RequestContext
) -> ListCreativesResponse:
"""List buyer-uploaded creatives for the dispatching principal.

Stub: returns an empty list. Full persistence requires wiring
``sync_creatives`` to a ``Creative`` ORM table and querying it
here. That end-to-end wiring is deferred — this stub satisfies
the v6.0 rc.1 boot-validation requirement.
"""
del req, ctx
return ListCreativesResponse(
query_summary={"total_matching": 0, "returned": 0},
pagination={"has_more": False},
creatives=[],
)


def _project_start_time(value: Any) -> datetime:
"""Project :class:`StartTiming` (root: ``'asap'`` | :class:`AwareDatetime`)
Expand Down
69 changes: 66 additions & 3 deletions examples/v3_reference_seller/tests/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,18 @@


def test_models_import_and_declare_tables() -> None:
from src.models import Account, Base, BuyerAgent, MediaBuy, Tenant
from src.models import Account, Base, BuyerAgent, MediaBuy, PerformanceFeedback, Tenant

table_names = {t.name for t in Base.metadata.tables.values()}
assert {"tenants", "buyer_agents", "accounts", "media_buys"} <= table_names
assert {
"tenants",
"buyer_agents",
"accounts",
"media_buys",
"performance_feedback",
} <= table_names
# Sanity: every model is in the metadata.
for cls in (Tenant, BuyerAgent, Account, MediaBuy):
for cls in (Tenant, BuyerAgent, Account, MediaBuy, PerformanceFeedback):
assert cls.__tablename__ in table_names


Expand Down Expand Up @@ -111,3 +117,60 @@ async def test_buyer_registry_returns_none_without_tenant() -> None:
cred = ApiKeyCredential(kind="api_key", key_id="any")
assert await registry.resolve_by_agent_url("https://x/") is None
assert await registry.resolve_by_credential(cred) is None


def test_platform_has_all_nine_sales_methods() -> None:
"""V3ReferenceSeller exposes all nine SalesPlatform methods."""
from src.platform import V3ReferenceSeller

required = {
"get_products",
"create_media_buy",
"update_media_buy",
"sync_creatives",
"get_media_buy_delivery",
"get_media_buys",
"provide_performance_feedback",
"list_creative_formats",
"list_creatives",
}
missing = required - set(dir(V3ReferenceSeller))
assert not missing, f"Missing methods: {missing}"


@pytest.mark.asyncio
async def test_list_creative_formats_returns_valid_response() -> None:
"""list_creative_formats returns a spec-valid empty catalog."""
from src.platform import V3ReferenceSeller

from adcp.types import ListCreativeFormatsRequest, ListCreativeFormatsResponse

platform = V3ReferenceSeller(sessionmaker=lambda: None) # type: ignore[arg-type]
req = ListCreativeFormatsRequest()
resp = await platform.list_creative_formats(req, ctx=None) # type: ignore[arg-type]
assert isinstance(resp, ListCreativeFormatsResponse)
assert resp.formats == []


@pytest.mark.asyncio
async def test_list_creatives_returns_valid_response() -> None:
"""list_creatives returns a spec-valid empty result."""
from src.platform import V3ReferenceSeller

from adcp.types import ListCreativesRequest, ListCreativesResponse

platform = V3ReferenceSeller(sessionmaker=lambda: None) # type: ignore[arg-type]
req = ListCreativesRequest()
resp = await platform.list_creatives(req, ctx=None) # type: ignore[arg-type]
assert isinstance(resp, ListCreativesResponse)
assert resp.creatives == []
assert resp.query_summary.total_matching == 0


def test_performance_feedback_table_has_idempotency_constraint() -> None:
"""PerformanceFeedback table declares the idempotency unique constraint."""
from src.models import Base

table = Base.metadata.tables["performance_feedback"]
constraint_names = {c.name for c in table.constraints}
assert "perf_feedback_idem_uk" in constraint_names
Loading