6666import random
6767from dataclasses import replace as _dc_replace
6868from datetime import datetime , timezone
69- from typing import TYPE_CHECKING , Any , ClassVar
69+ from typing import TYPE_CHECKING , Any , ClassVar , cast
70+ from urllib .parse import urlsplit
7071
7172from sqlalchemy import select
7273
9798)
9899from adcp .decisioning .specialisms import SalesPlatform
99100from adcp .server import current_tenant
101+ from adcp .server .helpers import valid_actions_for_status
100102from adcp .types import (
101103 BusinessEntity ,
102104 CreateMediaBuyRequest ,
112114 ListCreativeFormatsResponse ,
113115 ListCreativesRequest ,
114116 ListCreativesResponse ,
117+ MediaBuyStatus ,
115118 Product ,
116119 ProvidePerformanceFeedbackRequest ,
117120 ProvidePerformanceFeedbackSuccessResponse ,
@@ -593,21 +596,99 @@ def _project_request_package_echo(pkg: Any) -> dict[str, Any]:
593596 if value is None :
594597 continue
595598 if hasattr (value , "model_dump" ):
596- out [field ] = value .model_dump (mode = "json" , exclude_none = True )
599+ out [field ] = _normalize_echo_urls ( value .model_dump (mode = "json" , exclude_none = True ) )
597600 elif isinstance (value , list ):
598- out [field ] = [
599- (
600- item .model_dump (mode = "json" , exclude_none = True )
601- if hasattr (item , "model_dump" )
602- else item
603- )
604- for item in value
605- ]
601+ out [field ] = _normalize_echo_urls (
602+ [
603+ (
604+ item .model_dump (mode = "json" , exclude_none = True )
605+ if hasattr (item , "model_dump" )
606+ else item
607+ )
608+ for item in value
609+ ]
610+ )
606611 else :
607612 out [field ] = value
608613 return out
609614
610615
616+ def _normalize_echo_urls (value : Any ) -> Any :
617+ """Keep buyer-supplied agent_url echoes byte-stable after Pydantic parsing."""
618+ if isinstance (value , dict ):
619+ normalized : dict [str , Any ] = {}
620+ for key , item in value .items ():
621+ if key == "agent_url" and isinstance (item , str ):
622+ parts = urlsplit (item )
623+ normalized [key ] = (
624+ item [:- 1 ]
625+ if item .endswith ("/" )
626+ and parts .scheme
627+ and parts .netloc
628+ and parts .path == "/"
629+ and not parts .query
630+ and not parts .fragment
631+ else item
632+ )
633+ else :
634+ normalized [key ] = _normalize_echo_urls (item )
635+ return normalized
636+ if isinstance (value , list ):
637+ return [_normalize_echo_urls (item ) for item in value ]
638+ return value
639+
640+
641+ def _format_dimensions (v1_format_id : str ) -> tuple [int , int ]:
642+ if "970x250" in v1_format_id :
643+ return (970 , 250 )
644+ if "728x90" in v1_format_id :
645+ return (728 , 90 )
646+ return (300 , 250 )
647+
648+
649+ def _product_format_options (
650+ * ,
651+ product_id : str ,
652+ name : str ,
653+ format_ids : list [dict [str , Any ]],
654+ ) -> list [dict [str , Any ]]:
655+ options : list [dict [str , Any ]] = []
656+ for i , fmt in enumerate (format_ids ):
657+ v1_format_id = str (fmt .get ("id" ) or "display_300x250" )
658+ v1_agent_url = str (fmt .get ("agent_url" ) or "https://reference.adcp.org" )
659+ option_id = f"reference_{ product_id } _{ i } "
660+ display_name = f"{ name } - { v1_format_id } "
661+ base = {
662+ "format_option_id" : option_id ,
663+ "display_name" : display_name ,
664+ "v1_format_ref" : [{"agent_url" : v1_agent_url , "id" : v1_format_id }],
665+ }
666+ if "video" in v1_format_id or "ctv" in v1_format_id :
667+ options .append (
668+ {
669+ ** base ,
670+ "format_kind" : "video_hosted" ,
671+ "params" : {},
672+ }
673+ )
674+ continue
675+
676+ width , height = _format_dimensions (v1_format_id )
677+ options .append (
678+ {
679+ ** base ,
680+ "format_kind" : "image" ,
681+ "params" : {
682+ "sizes" : [{"width" : width , "height" : height }],
683+ "asset_source" : "buyer_uploaded" ,
684+ "ssl_required" : True ,
685+ "image_formats" : ["jpg" , "png" , "gif" ],
686+ },
687+ }
688+ )
689+ return options
690+
691+
611692def _projected_package_state (state : dict [str , Any ]) -> dict [str , Any ]:
612693 """Project a shadow-store package entry onto the wire-shape fields.
613694
@@ -857,42 +938,52 @@ async def get_products(
857938 channel = upstream_row .get ("channel" , "display" )
858939 fallback_id = "display_300x250" if channel == "display" else "video_16x9_30s"
859940 format_ids = [{"agent_url" : agent_url , "id" : fallback_id }]
860- products .append (
861- Product .model_validate (
941+ format_options = _product_format_options (
942+ product_id = upstream_row ["product_id" ],
943+ name = upstream_row ["name" ],
944+ format_ids = format_ids ,
945+ )
946+ product_payload = {
947+ "product_id" : upstream_row ["product_id" ],
948+ "name" : upstream_row ["name" ],
949+ "description" : upstream_row .get ("name" , "" ),
950+ "delivery_type" : upstream_row .get ("delivery_type" , "non_guaranteed" ),
951+ "publisher_properties" : [
952+ # The reference seller is a single-publisher
953+ # demo; ``selection_type='all'`` matches the
954+ # spec's "all properties from this publisher"
955+ # discriminator. Multi-publisher adopters
956+ # narrow with ``selection_type='by_id'`` /
957+ # ``'by_tag'``.
862958 {
863- "product_id" : upstream_row ["product_id" ],
864- "name" : upstream_row ["name" ],
865- "description" : upstream_row .get ("name" , "" ),
866- "delivery_type" : upstream_row .get ("delivery_type" , "non_guaranteed" ),
867- "publisher_properties" : [
868- # The reference seller is a single-publisher
869- # demo; ``selection_type='all'`` matches the
870- # spec's "all properties from this publisher"
871- # discriminator. Multi-publisher adopters
872- # narrow with ``selection_type='by_id'`` /
873- # ``'by_tag'``.
874- {
875- "publisher_domain" : "reference.adcp.org" ,
876- "selection_type" : "all" ,
877- }
878- ],
879- "format_ids" : format_ids ,
880- "reporting_capabilities" : {
881- "available_reporting_frequencies" : ["daily" ],
882- "expected_delay_minutes" : 240 ,
883- "timezone" : "UTC" ,
884- "supports_webhooks" : False ,
885- "available_metrics" : [
886- "impressions" ,
887- "spend" ,
888- "clicks" ,
889- ],
890- "date_range_support" : "date_range" ,
891- },
892- "pricing_options" : [pricing_option ],
959+ "publisher_domain" : "reference.adcp.org" ,
960+ "selection_type" : "all" ,
893961 }
894- )
895- )
962+ ],
963+ "format_ids" : format_ids ,
964+ "format_options" : format_options ,
965+ "reporting_capabilities" : {
966+ "available_reporting_frequencies" : ["daily" ],
967+ "expected_delay_minutes" : 240 ,
968+ "timezone" : "UTC" ,
969+ "supports_webhooks" : False ,
970+ "available_metrics" : [
971+ "impressions" ,
972+ "spend" ,
973+ "clicks" ,
974+ ],
975+ "date_range_support" : "date_range" ,
976+ },
977+ "pricing_options" : [pricing_option ],
978+ }
979+ product = Product .model_validate (product_payload )
980+ # The generated ProductFormatDeclaration currently omits the
981+ # canonical discriminator fields during validation. Restore
982+ # the already-built wire declarations so 3.1 translators see
983+ # the published closed format set.
984+ product .format_ids = format_ids # type: ignore[assignment]
985+ product .format_options = format_options # type: ignore[assignment]
986+ products .append (product )
896987 return GetProductsResponse (products = products )
897988
898989 # ----- refine_get_products ---------------------------------------------
@@ -1393,13 +1484,15 @@ async def update_media_buy(
13931484 if response_status == "pending_creatives" and any_creatives :
13941485 response_status = "pending_start"
13951486
1396- return UpdateMediaBuySuccessResponse .model_validate (
1397- {
1398- "media_buy_id" : media_buy_id ,
1399- "status" : response_status ,
1400- "revision" : revision ,
1401- "affected_packages" : affected_packages or None ,
1402- }
1487+ return cast (
1488+ UpdateMediaBuySuccessResponse ,
1489+ cast (Any , UpdateMediaBuySuccessResponse ).model_construct (
1490+ media_buy_id = media_buy_id ,
1491+ media_buy_status = MediaBuyStatus (response_status ),
1492+ status = "completed" ,
1493+ revision = revision ,
1494+ affected_packages = affected_packages or None ,
1495+ ),
14031496 )
14041497
14051498 # ----- sync_creatives --------------------------------------------------
@@ -1696,6 +1789,7 @@ async def get_media_buys(
16961789 "packages" : packages ,
16971790 "created_at" : order .get ("created_at" ),
16981791 "updated_at" : order .get ("updated_at" ),
1792+ "valid_actions" : valid_actions_for_status (wire_status ),
16991793 }
17001794 if buy_state .get ("context" ) is not None :
17011795 media_buy ["context" ] = buy_state ["context" ]
@@ -1709,7 +1803,12 @@ async def get_media_buys(
17091803 "offset" : offset ,
17101804 },
17111805 )
1712- return GetMediaBuysResponse .model_validate ({"media_buys" : media_buys })
1806+ return GetMediaBuysResponse .model_validate (
1807+ {
1808+ "media_buys" : media_buys ,
1809+ "sandbox" : getattr (ctx .account , "mode" , None ) in {"mock" , "sandbox" },
1810+ }
1811+ )
17131812
17141813 # ----- provide_performance_feedback ------------------------------------
17151814
0 commit comments