Skip to content

Commit cb1f48a

Browse files
committed
Fix reference seller 3.1 storyboard compatibility
1 parent c3837af commit cb1f48a

11 files changed

Lines changed: 493 additions & 20 deletions

examples/seller_agent.py

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import os
1919
import uuid
20+
from datetime import datetime, timezone
2021
from typing import Any
2122

2223
from adcp.server import (
@@ -79,6 +80,7 @@
7980
accounts: dict[str, dict[str, Any]] = {}
8081
media_buys: dict[str, dict[str, Any]] = {}
8182
creatives: dict[str, dict[str, Any]] = {}
83+
open_impairments: dict[tuple[str, str], dict[str, Any]] = {}
8284
proposals: dict[str, dict[str, Any]] = {}
8385
# Used when no account_id is present; single-tenant demo shortcut.
8486
# Real sellers must scope directives and tasks by account_id.
@@ -89,6 +91,78 @@
8991
# Seeded creative formats keyed by the string format ID the storyboard supplies.
9092
# list_creative_formats merges these in so storyboard references resolve.
9193
seeded_creative_formats: dict[str, dict[str, Any]] = {}
94+
95+
96+
def _now_z() -> str:
97+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
98+
99+
100+
def _package_creative_ids(pkg: dict[str, Any]) -> list[str]:
101+
ids: list[str] = []
102+
for assignment in pkg.get("creative_assignments") or []:
103+
if isinstance(assignment, dict) and assignment.get("creative_id"):
104+
ids.append(str(assignment["creative_id"]))
105+
for creative in pkg.get("creatives") or []:
106+
if isinstance(creative, dict) and creative.get("creative_id"):
107+
ids.append(str(creative["creative_id"]))
108+
return list(dict.fromkeys(ids))
109+
110+
111+
def _health_fields_for_media_buy(media_buy_id: str | None, mb: dict[str, Any]) -> dict[str, Any]:
112+
impaired_packages: dict[str, list[str]] = {}
113+
for pkg in mb.get("packages", []):
114+
package_id = pkg.get("package_id")
115+
if not package_id:
116+
continue
117+
creative_ids = _package_creative_ids(pkg)
118+
if not creative_ids:
119+
continue
120+
if any(
121+
creatives.get(creative_id, {}).get("status") in {"approved", "active"}
122+
for creative_id in creative_ids
123+
):
124+
continue
125+
for creative_id in creative_ids:
126+
creative_status = creatives.get(creative_id, {}).get("status")
127+
if creative_status in {"rejected"}:
128+
package_ids = impaired_packages.setdefault(creative_id, [])
129+
if package_id not in package_ids:
130+
package_ids.append(package_id)
131+
media_buy_key = media_buy_id or "__anonymous__"
132+
active_keys = {(media_buy_key, creative_id) for creative_id in impaired_packages}
133+
for key in [
134+
key for key in open_impairments if key[0] == media_buy_key and key not in active_keys
135+
]:
136+
del open_impairments[key]
137+
138+
impairments: list[dict[str, Any]] = []
139+
for creative_id, package_ids in impaired_packages.items():
140+
creative = creatives.get(creative_id, {})
141+
key = (media_buy_key, creative_id)
142+
if key not in open_impairments:
143+
open_impairments[key] = {
144+
"impairment_id": f"imp-{uuid.uuid4().hex[:8]}",
145+
"observed_at": creative.get("status_changed_at") or _now_z(),
146+
}
147+
impairment = open_impairments[key]
148+
impairments.append(
149+
{
150+
"impairment_id": impairment["impairment_id"],
151+
"resource_type": "creative",
152+
"resource_id": creative_id,
153+
"package_ids": package_ids,
154+
"transition": {"from": "approved", "to": "rejected"},
155+
"reason_code": "content_rejected",
156+
"reason": "Creative is no longer approved for delivery.",
157+
"observed_at": impairment["observed_at"],
158+
"remediation": "Assign an approved replacement creative.",
159+
}
160+
)
161+
if impairments:
162+
return {"health": "impaired", "impairments": impairments}
163+
return {"health": "ok", "impairments": []}
164+
165+
92166
# Single-shot directives registered by force_create_media_buy_arm; keyed by account_id.
93167
pending_directives: dict[str, dict[str, Any]] = {}
94168
# Tasks registered when create_media_buy consumes a 'submitted' directive; keyed by task_id.
@@ -310,9 +384,7 @@ async def sync_governance(self, params: dict[str, Any], context: Any = None) ->
310384
{
311385
"account": acct_ref,
312386
"status": "synced",
313-
"governance_agents": [
314-
{"url": a.get("url"), "categories": a.get("categories", [])} for a in agents
315-
],
387+
"governance_agents": [{"url": a.get("url")} for a in agents],
316388
}
317389
)
318390
return sync_governance_response(results)
@@ -345,7 +417,7 @@ async def get_products(self, params: dict[str, Any], context: Any = None) -> dic
345417
}
346418
]
347419
return {
348-
**products_response(PRODUCTS),
420+
**products_response(PRODUCTS, cache_scope="public"),
349421
"proposals": [
350422
{
351423
"proposal_id": proposal_id,
@@ -355,7 +427,7 @@ async def get_products(self, params: dict[str, Any], context: Any = None) -> dic
355427
}
356428
],
357429
}
358-
return products_response(PRODUCTS)
430+
return products_response(PRODUCTS, cache_scope="public")
359431

360432
async def create_media_buy(self, params: dict[str, Any], context: Any = None) -> dict[str, Any]:
361433
account_id = (params.get("account") or {}).get("account_id") or _DEFAULT_ACCOUNT_ID
@@ -478,6 +550,7 @@ async def get_media_buys(self, params: dict[str, Any], context: Any = None) -> d
478550
"currency": mb.get("currency", "USD"),
479551
"packages": mb.get("packages", []),
480552
"total_budget": total_budget,
553+
**_health_fields_for_media_buy(mb_id, mb),
481554
}
482555
)
483556
return media_buys_response(results)
@@ -605,12 +678,11 @@ async def sync_creatives(self, params: dict[str, Any], context: Any = None) -> d
605678
results = []
606679
for c in params.get("creatives", []):
607680
creative_id = c.get("creative_id") or f"c-{uuid.uuid4().hex[:8]}"
608-
creatives[creative_id] = {**c, "status": "approved"}
681+
creatives[creative_id] = {**c, "status": "approved", "status_changed_at": _now_z()}
609682
results.append(
610683
{
611684
"creative_id": creative_id,
612685
"action": "created",
613-
"status": "approved",
614686
}
615687
)
616688
# Transition any media buys waiting on creatives to pending_start
@@ -696,6 +768,7 @@ async def force_creative_status(
696768
current_state=prev,
697769
)
698770
c["status"] = status
771+
c["status_changed_at"] = _now_z()
699772
return {"previous_state": prev, "current_state": status}
700773

701774
async def simulate_delivery(
@@ -900,6 +973,7 @@ async def seed_creative(
900973
data = dict(fixture or {})
901974
cid = creative_id or data.get("creative_id") or f"c-seeded-{uuid.uuid4().hex[:8]}"
902975
data["creative_id"] = cid
976+
data.setdefault("status_changed_at", _now_z())
903977
creatives[cid] = data
904978
return {"creative_id": cid}
905979

src/adcp/server/mcp_tools.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,28 @@
3333
logger = logging.getLogger(__name__)
3434

3535

36+
_MEDIA_BUY_STATUS_VALUES = {
37+
"draft",
38+
"pending_creatives",
39+
"pending_start",
40+
"active",
41+
"paused",
42+
"completed",
43+
"canceled",
44+
"cancelled",
45+
"rejected",
46+
}
47+
48+
49+
def _looks_like_sync_media_buy_success(method_name: str, result: dict[str, Any]) -> bool:
50+
return (
51+
method_name in {"create_media_buy", "update_media_buy"}
52+
and "media_buy_id" in result
53+
and "errors" not in result
54+
and "task_id" not in result
55+
)
56+
57+
3658
def _normalize_response_envelope(
3759
method_name: str, result: dict[str, Any], raw_params: dict[str, Any]
3860
) -> None:
@@ -44,6 +66,16 @@ def _normalize_response_envelope(
4466
wholesale reads must be explicit so a seller doesn't accidentally label
4567
account-specific inventory as globally cacheable.
4668
"""
69+
if _looks_like_sync_media_buy_success(method_name, result):
70+
raw_status = result.get("status")
71+
media_buy_status = result.get("media_buy_status")
72+
if media_buy_status is None and raw_status in _MEDIA_BUY_STATUS_VALUES:
73+
if raw_status != "completed":
74+
result["media_buy_status"] = raw_status
75+
result["status"] = "completed"
76+
elif media_buy_status is not None and raw_status in {None, media_buy_status}:
77+
result["status"] = "completed"
78+
4779
result.setdefault("status", "completed")
4880
if (
4981
method_name in {"get_products", "get_signals"}

src/adcp/server/responses.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,6 @@ def media_buy_response(
364364
resp["buyer_ref"] = buyer_ref
365365
if status is not None:
366366
resp["media_buy_status"] = status
367-
resp["status"] = status
368367
if valid_actions is None:
369368
resp["valid_actions"] = valid_actions_for_status(status)
370369
else:
@@ -405,7 +404,6 @@ def update_media_buy_response(
405404
resp["affected_packages"] = _serialize(affected_packages)
406405
if status is not None:
407406
resp["media_buy_status"] = status
408-
resp["status"] = status
409407
if valid_actions is None:
410408
resp["valid_actions"] = valid_actions_for_status(status)
411409
else:

src/adcp/types/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,8 @@
583583
UpdateMediaBuyPackagesRequest,
584584
UpdateMediaBuyPropertiesRequest,
585585
UpdateMediaBuyResponse1,
586+
UpdateMediaBuyResponse3,
587+
UpdateMediaBuySubmittedResponse,
586588
UpdateMediaBuySuccessResponse,
587589
UrlDaastAsset,
588590
UrlFormatAsset,
@@ -1255,8 +1257,10 @@ def __init__(self, *args: object, **kwargs: object) -> None:
12551257
"UpdateContentStandardsSuccessResponse",
12561258
"UpdateMediaBuyErrorResponse",
12571259
"UpdateMediaBuyResponse1",
1260+
"UpdateMediaBuyResponse3",
12581261
"UpdateMediaBuyPackagesRequest",
12591262
"UpdateMediaBuyPropertiesRequest",
1263+
"UpdateMediaBuySubmittedResponse",
12601264
"UpdateMediaBuySuccessResponse",
12611265
"UrlDaastAsset",
12621266
"UrlPreviewRender",

src/adcp/types/_generated.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1459,6 +1459,7 @@
14591459
UpdateMediaBuyResponse,
14601460
UpdateMediaBuyResponse1,
14611461
UpdateMediaBuyResponse2,
1462+
UpdateMediaBuyResponse3,
14621463
)
14631464
from adcp.types.generated_poc.pricing_options.cpa_option import CpaPricingOption
14641465
from adcp.types.generated_poc.pricing_options.cpc_option import CpcPricingOption
@@ -2983,6 +2984,7 @@
29832984
"UpdateMediaBuyResponse",
29842985
"UpdateMediaBuyResponse1",
29852986
"UpdateMediaBuyResponse2",
2987+
"UpdateMediaBuyResponse3",
29862988
"UpdateMediaBuySubmitted",
29872989
"UpdateMediaBuyWorking",
29882990
"UpdatePropertyListRequest",

src/adcp/types/aliases.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@
189189
)
190190
UpdateMediaBuyResponse1 = getattr(_g, "UpdateMediaBuyResponse1", _g.UpdateMediaBuyResponse)
191191
UpdateMediaBuyResponse2 = getattr(_g, "UpdateMediaBuyResponse2", _g.UpdateMediaBuyResponse)
192+
UpdateMediaBuyResponse3 = getattr(_g, "UpdateMediaBuyResponse3", _g.UpdateMediaBuyResponse)
192193
ValidateContentDeliveryResponse1 = getattr(
193194
_g, "ValidateContentDeliveryResponse1", _g.ValidateContentDeliveryResponse
194195
)
@@ -475,6 +476,9 @@ def process_result(result: SyncCatalogResult) -> None:
475476
UpdateMediaBuyErrorResponse: TypeAlias = UpdateMediaBuyResponse2
476477
"""Error response - media buy update failed, no changes applied."""
477478

479+
UpdateMediaBuySubmittedResponse: TypeAlias = UpdateMediaBuyResponse3
480+
"""Submitted (async) envelope - media buy update accepted for async processing."""
481+
478482
# Get Account Financials Response Variants
479483
GetAccountFinancialsSuccessResponse: TypeAlias = GetAccountFinancialsResponse1
480484
"""Success response - account financials retrieved."""
@@ -1725,6 +1729,7 @@ class UnknownGroupAsset(_BaseGroupAsset):
17251729
# Update media buy responses
17261730
"UpdateMediaBuySuccessResponse",
17271731
"UpdateMediaBuyErrorResponse",
1732+
"UpdateMediaBuySubmittedResponse",
17281733
# Validate content delivery responses
17291734
"ValidateContentDeliverySuccessResponse",
17301735
"ValidateContentDeliveryErrorResponse",

src/adcp/types/generated_poc/media_buy/create_media_buy_response.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
from __future__ import annotations
66

7-
from ..core.protocol_envelope import ProtocolEnvelope
87
from ..core.version_envelope import AdcpVersionEnvelope
98

109

@@ -14,30 +13,63 @@
1413
# historical numbered variants as ergonomic construction/parsing aliases.
1514
from typing import Any, Literal, TypeAlias
1615

17-
from pydantic import ConfigDict
16+
from pydantic import ConfigDict, model_validator
1817

1918
from ..core import error as error_1
2019
from ..core import package as package_1
2120
from ..enums import media_buy_status as media_buy_status_1
2221

2322

23+
_MEDIA_BUY_STATUS_VALUES = {
24+
"pending_creatives",
25+
"pending_start",
26+
"active",
27+
"paused",
28+
"rejected",
29+
"canceled",
30+
}
31+
32+
33+
def _value(value: Any) -> Any:
34+
return getattr(value, "value", value)
35+
36+
2437
class CreateMediaBuyResponse1(AdcpVersionEnvelope):
2538
model_config = ConfigDict(extra='allow')
2639
media_buy_id: str
2740
packages: list[package_1.Package]
2841
buyer_ref: str | None = None
2942
media_buy_status: media_buy_status_1.MediaBuyStatus | None = None
30-
status: media_buy_status_1.MediaBuyStatus | None = None
43+
status: Literal["completed"]
44+
45+
@model_validator(mode='before')
46+
@classmethod
47+
def _normalize_legacy_status(cls, data: Any) -> Any:
48+
if not isinstance(data, dict):
49+
return data
50+
raw_status = _value(data.get("status"))
51+
media_buy_status = _value(data.get("media_buy_status"))
52+
if raw_status is None:
53+
data = dict(data)
54+
data["status"] = "completed"
55+
elif media_buy_status is None and raw_status in _MEDIA_BUY_STATUS_VALUES:
56+
data = dict(data)
57+
data["media_buy_status"] = raw_status
58+
data["status"] = "completed"
59+
elif media_buy_status is not None and raw_status == media_buy_status:
60+
data = dict(data)
61+
data["status"] = "completed"
62+
return data
3163

3264

3365
class CreateMediaBuyResponse2(AdcpVersionEnvelope):
3466
model_config = ConfigDict(extra='allow')
3567
errors: list[error_1.Error]
3668

3769

38-
class CreateMediaBuyResponse3(AdcpVersionEnvelope, ProtocolEnvelope):
70+
class CreateMediaBuyResponse3(AdcpVersionEnvelope):
3971
model_config = ConfigDict(extra='allow')
40-
status: Any = 'submitted'
72+
status: Literal["submitted"] = "submitted"
4173
task_id: str
4274

4375

0 commit comments

Comments
 (0)