From 10cff0b0ab525431112c00903b8e42f10cc8af97 Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 16 Jul 2026 18:40:35 +0600 Subject: [PATCH 01/17] chore: restore formatting and import order masked by stale ruff cache `make quality` was passing on a stale .ruff_cache verdict: running ruff with --no-cache surfaced 103 latent violations already present in HEAD (98 unsorted imports, 2 unused imports, 2 quoted annotations) plus formatting drift in 13 files. All auto-fixed with ruff format + ruff check --fix; no behavioral changes. Co-Authored-By: Claude Fable 5 --- scripts/ops/gam_helper.py | 2 +- src/admin/blueprints/operations.py | 2 +- src/admin/sync_api.py | 3 +- src/services/background_sync_service.py | 1 + .../harness/test_forward_compat_acceptance.py | 11 -- tests/harness/test_mcp_client_dispatch.py | 3 - tests/integration/test_a2a_brand_manifest.py | 2 +- tests/integration/test_a2a_error_responses.py | 2 +- .../test_a2a_response_compliance.py | 2 +- .../test_a2a_response_message_fields.py | 2 +- .../integration/test_a2a_skill_invocation.py | 2 +- .../test_create_media_buy_creation_paths.py | 103 ++++++------- ...st_create_media_buy_creative_validation.py | 145 ++++++++++-------- .../test_create_media_buy_integration.py | 115 ++++++-------- ...st_create_media_buy_persistence_details.py | 26 +--- tests/integration/test_error_paths.py | 7 +- ..._list_authorized_properties_integration.py | 2 +- tests/unit/test_a2a_auth_optional.py | 2 +- .../unit/test_a2a_brand_manifest_parameter.py | 2 +- tests/unit/test_a2a_call_context_builder.py | 1 - .../unit/test_a2a_function_call_validation.py | 1 + tests/unit/test_a2a_handler_correctness.py | 3 + tests/unit/test_a2a_nl_auth_redundancy.py | 2 +- tests/unit/test_a2a_tenant_detection_order.py | 1 + .../test_a2a_testing_context_extraction.py | 1 + tests/unit/test_a2a_transport_contract.py | 2 +- tests/unit/test_adapter_packages_fix.py | 2 +- tests/unit/test_adcp_exceptions.py | 9 ++ tests/unit/test_auth_context.py | 4 + .../test_authorized_properties_behavioral.py | 4 - .../test_create_media_buy_identity_guards.py | 3 +- .../unit/test_create_media_buy_misc_guards.py | 26 ++-- ...test_create_media_buy_overbook_warnings.py | 6 +- .../test_create_media_buy_pure_functions.py | 6 +- ...est_create_media_buy_request_validation.py | 20 +-- .../test_create_media_buy_setup_bypass.py | 26 ++-- tests/unit/test_direct_get_products.py | 1 + tests/unit/test_error_boundary_translation.py | 28 ++-- tests/unit/test_error_format_consistency.py | 2 +- tests/unit/test_fastapi_app_regression.py | 34 ++-- tests/unit/test_mcp_auth_middleware.py | 1 - tests/unit/test_no_contextvar_in_a2a.py | 2 + tests/unit/test_openapi_surface.py | 3 +- .../unit/test_products_transport_wrappers.py | 4 +- tests/unit/test_rest_api_endpoints.py | 2 +- tests/unit/test_rest_api_products.py | 2 +- tests/unit/test_task_management_auth.py | 2 +- 47 files changed, 283 insertions(+), 349 deletions(-) diff --git a/scripts/ops/gam_helper.py b/scripts/ops/gam_helper.py index 16ef2ec800..67e25b3845 100644 --- a/scripts/ops/gam_helper.py +++ b/scripts/ops/gam_helper.py @@ -19,7 +19,7 @@ def get_ad_manager_client_for_tenant(tenant_id: str) -> ad_manager.AdManagerClie Supports both OAuth (refresh token) and service account authentication, determined by the tenant's adapter config (gam_auth_method field). - + Args: tenant_id: The tenant ID to get the client for diff --git a/src/admin/blueprints/operations.py b/src/admin/blueprints/operations.py index d9d71424e1..949850cd89 100644 --- a/src/admin/blueprints/operations.py +++ b/src/admin/blueprints/operations.py @@ -155,7 +155,7 @@ def reporting(tenant_id): currency = str(currency_limit.currency_code) return render_template("gam_reporting.html", tenant=tenant, currency=currency) - + @operations_bp.route("/media-buy/", methods=["GET"]) @require_tenant_access() diff --git a/src/admin/sync_api.py b/src/admin/sync_api.py index b6ad8e754e..1aa6cbaa3c 100644 --- a/src/admin/sync_api.py +++ b/src/admin/sync_api.py @@ -493,10 +493,9 @@ def sync_tenant_orders(tenant_id: str) -> tuple[Response, int]: try: # Initialize GAM client - from src.services.gam_orders_service import GAMOrdersService - from src.adapters.google_ad_manager import GoogleAdManager from src.core.schemas import Principal + from src.services.gam_orders_service import GAMOrdersService # Create dummy principal for sync (no advertiser needed for order discovery) principal = Principal( diff --git a/src/services/background_sync_service.py b/src/services/background_sync_service.py index cc642076ff..1b2df87e56 100644 --- a/src/services/background_sync_service.py +++ b/src/services/background_sync_service.py @@ -606,6 +606,7 @@ def update_progress(phase: str, phase_num: int, count: int = 0): try: from src.services.gam_advertisers_sync import _build_gam_client_for_tenant from src.services.gam_orders_service import GAMOrdersService + with _sync_session() as db: GAMOrdersService(db).sync_tenant_orders(tenant_id, _build_gam_client_for_tenant(tenant_id)) except Exception as _oe: diff --git a/tests/harness/test_forward_compat_acceptance.py b/tests/harness/test_forward_compat_acceptance.py index f0a16283ed..92a66650e5 100644 --- a/tests/harness/test_forward_compat_acceptance.py +++ b/tests/harness/test_forward_compat_acceptance.py @@ -152,7 +152,6 @@ class TestMcpForwardCompat: def test_production_accepts_payload(self, label: str, payload: dict): """In production mode, get_products accepts various payload shapes.""" from fastmcp import Client - from src.core.main import mcp async def _call(): @@ -189,7 +188,6 @@ async def _call(): def test_dev_rejects_unknown_top_level_fields(self, label: str, payload: dict): """In dev mode, unknown top-level fields are rejected by TypeAdapter.""" from fastmcp import Client - from src.core.main import mcp # Only test payloads that have top-level unknowns (not normalized deprecations) @@ -343,7 +341,6 @@ class TestDeepStripRetryE2E: def test_nested_brand_extra_triggers_retry_and_succeeds(self): """Brand with future field: TypeAdapter rejects → deep-strip → retry → ok.""" from fastmcp import Client - from src.core.main import mcp async def _call(): @@ -378,7 +375,6 @@ async def _call(): def test_nested_context_extra_triggers_retry_and_succeeds(self): """Context with future field: same retry path.""" from fastmcp import Client - from src.core.main import mcp async def _call(): @@ -413,7 +409,6 @@ async def _call(): def test_stripping_no_change_does_not_retry(self): """If deep-strip doesn't change args, middleware raises original error (no infinite loop).""" from fastmcp import Client - from src.core.main import mcp # Send a field with wrong TYPE (int where string expected) — deep-strip @@ -459,7 +454,6 @@ def test_brand_domain_preserved_after_strip(self): receives brand.domain exactly as sent. """ from fastmcp import Client - from src.core.main import mcp captured_req = {} @@ -512,7 +506,6 @@ async def capturing_impl(req, identity=None): def test_context_session_id_preserved_after_strip(self): """Buyer sends context with extra field. After strip, session_id preserved.""" from fastmcp import Client - from src.core.main import mcp captured_req = {} @@ -562,7 +555,6 @@ def test_multiple_fields_all_preserved(self): After strip + retry, all known data arrives intact. """ from fastmcp import Client - from src.core.main import mcp captured_req = {} @@ -630,7 +622,6 @@ def test_business_logic_error_propagates_through_middleware(self): Sending none should return a clear error, not be silently accepted. """ from fastmcp import Client - from src.core.main import mcp async def _call(): @@ -668,7 +659,6 @@ def test_deep_strip_succeeds_but_impl_error_propagates(self): This tests that the retry path doesn't eat the _impl error. """ from fastmcp import Client - from src.core.main import mcp async def _call(): @@ -890,7 +880,6 @@ def test_empty_anyof_variants_passes_through(self): def test_concurrent_calls_dont_interfere(self): """Two concurrent middleware calls — patches don't leak between them.""" from fastmcp import Client - from src.core.main import mcp async def _call(): diff --git a/tests/harness/test_mcp_client_dispatch.py b/tests/harness/test_mcp_client_dispatch.py index a0c2631a7c..c7b337e222 100644 --- a/tests/harness/test_mcp_client_dispatch.py +++ b/tests/harness/test_mcp_client_dispatch.py @@ -27,7 +27,6 @@ class TestMcpClientDispatch: def test_client_mcp_succeeds_through_pipeline(self): """Call get_adcp_capabilities via Client(mcp) — exercises full middleware chain.""" from fastmcp import Client - from src.core.main import mcp identity = _make_identity() @@ -52,7 +51,6 @@ def test_dev_mode_rejects_unknown_fields(self): fields the seller agent doesn't support. """ from fastmcp import Client - from src.core.main import mcp identity = _make_identity() @@ -79,7 +77,6 @@ def test_production_mode_strips_unknown_fields(self): before TypeAdapter validates. """ from fastmcp import Client - from src.core.main import mcp identity = _make_identity() diff --git a/tests/integration/test_a2a_brand_manifest.py b/tests/integration/test_a2a_brand_manifest.py index 70e63fd48e..6b20be63b4 100644 --- a/tests/integration/test_a2a_brand_manifest.py +++ b/tests/integration/test_a2a_brand_manifest.py @@ -12,8 +12,8 @@ import pytest from a2a.types import MessageSendParams, Task from a2a.utils.errors import InvalidParamsError, ServerError - from src.a2a_server.adcp_a2a_server import AdCPRequestHandler + from src.core.resolved_identity import ResolvedIdentity from tests.utils.a2a_helpers import create_a2a_message_with_skill diff --git a/tests/integration/test_a2a_error_responses.py b/tests/integration/test_a2a_error_responses.py index 609f575bb8..2fb121159a 100644 --- a/tests/integration/test_a2a_error_responses.py +++ b/tests/integration/test_a2a_error_responses.py @@ -16,8 +16,8 @@ import pytest from a2a.types import Message, MessageSendParams, Task from sqlalchemy import delete - from src.a2a_server.adcp_a2a_server import AdCPRequestHandler + from src.core.database.database_session import get_db_session # fmt: off diff --git a/tests/integration/test_a2a_response_compliance.py b/tests/integration/test_a2a_response_compliance.py index f1de93d82d..92dee6a43e 100644 --- a/tests/integration/test_a2a_response_compliance.py +++ b/tests/integration/test_a2a_response_compliance.py @@ -9,8 +9,8 @@ """ import pytest - from src.a2a_server.adcp_a2a_server import AdCPRequestHandler + from src.core.schemas import ( CreateMediaBuySuccess, GetMediaBuyDeliveryResponse, diff --git a/tests/integration/test_a2a_response_message_fields.py b/tests/integration/test_a2a_response_message_fields.py index 3ec68b8048..e0b427cc82 100644 --- a/tests/integration/test_a2a_response_message_fields.py +++ b/tests/integration/test_a2a_response_message_fields.py @@ -18,8 +18,8 @@ from unittest.mock import MagicMock, patch import pytest - from src.a2a_server.adcp_a2a_server import AdCPRequestHandler + from src.core.resolved_identity import ResolvedIdentity from tests.helpers.a2a_response_validator import assert_valid_skill_response from tests.helpers.external_service import is_external_service_exception diff --git a/tests/integration/test_a2a_skill_invocation.py b/tests/integration/test_a2a_skill_invocation.py index cd532b16a2..1c5dba22e1 100644 --- a/tests/integration/test_a2a_skill_invocation.py +++ b/tests/integration/test_a2a_skill_invocation.py @@ -11,8 +11,8 @@ import pytest from a2a.types import DataPart, Message, MessageSendParams, Part, Role, Task, TaskState, TaskStatus - from src.a2a_server.adcp_a2a_server import AdCPRequestHandler + from tests.utils.a2a_helpers import create_a2a_message_with_skill, create_a2a_text_message pytestmark = [pytest.mark.integration, pytest.mark.requires_db] diff --git a/tests/integration/test_create_media_buy_creation_paths.py b/tests/integration/test_create_media_buy_creation_paths.py index f2c45287c7..e13a6d8e9e 100644 --- a/tests/integration/test_create_media_buy_creation_paths.py +++ b/tests/integration/test_create_media_buy_creation_paths.py @@ -53,9 +53,7 @@ class TestMultiPackageBuy: would go undetected. """ - async def test_two_packages_creates_two_media_package_rows( - self, sample_tenant, sample_principal, sample_products - ): + async def test_two_packages_creates_two_media_package_rows(self, sample_tenant, sample_principal, sample_products): """PATH-001: 2 packages in request → 2 MediaPackage rows in DB, both linked to same media_buy_id.""" from src.core.database.models import MediaPackage as DBMediaPackage from src.core.schemas import CreateMediaBuyRequest @@ -86,15 +84,12 @@ async def test_two_packages_creates_two_media_package_rows( result = await _create_media_buy_impl(req=req, identity=identity) assert isinstance(result.response, CreateMediaBuySuccess), ( - f"Expected success for multi-package buy. " - f"Errors: {getattr(result.response, 'errors', None)}" + f"Expected success for multi-package buy. Errors: {getattr(result.response, 'errors', None)}" ) media_buy_id = result.response.media_buy_id with get_db_session() as session: - packages = session.scalars( - select(DBMediaPackage).where(DBMediaPackage.media_buy_id == media_buy_id) - ).all() + packages = session.scalars(select(DBMediaPackage).where(DBMediaPackage.media_buy_id == media_buy_id)).all() assert len(packages) == 2, ( f"Expected 2 MediaPackage rows for a 2-package buy, got {len(packages)}. " @@ -103,9 +98,7 @@ async def test_two_packages_creates_two_media_package_rows( package_ids = {p.package_id for p in packages} assert len(package_ids) == 2, "Each package must have a distinct package_id." for pkg in packages: - assert pkg.media_buy_id == media_buy_id, ( - "Both MediaPackage rows must reference the same media_buy_id." - ) + assert pkg.media_buy_id == media_buy_id, "Both MediaPackage rows must reference the same media_buy_id." # =========================================================================== @@ -124,9 +117,7 @@ class TestPoNumberPreserved: not carry the reference. """ - async def test_po_number_stored_in_raw_request( - self, sample_tenant, sample_principal, sample_products - ): + async def test_po_number_stored_in_raw_request(self, sample_tenant, sample_principal, sample_products): """PATH-002: po_number='PO-2026-TEST' in request → found in MediaBuy.raw_request.""" from src.core.database.models import MediaBuy as DBMediaBuy from src.core.schemas import CreateMediaBuyRequest @@ -142,16 +133,12 @@ async def test_po_number_stored_in_raw_request( start_time=_future(1), end_time=_future(8), po_number=po, - packages=[ - {"product_id": "guaranteed_display", "budget": 5000.0, "pricing_option_id": "cpm_usd_fixed"} - ], + packages=[{"product_id": "guaranteed_display", "budget": 5000.0, "pricing_option_id": "cpm_usd_fixed"}], ) result = await _create_media_buy_impl(req=req, identity=identity) - assert isinstance(result.response, CreateMediaBuySuccess), ( - f"Errors: {getattr(result.response, 'errors', None)}" - ) + assert isinstance(result.response, CreateMediaBuySuccess), f"Errors: {getattr(result.response, 'errors', None)}" with get_db_session() as session: row = session.scalars( @@ -212,15 +199,11 @@ async def test_geo_targeting_overlay_preserved_in_package_config( result = await _create_media_buy_impl(req=req, identity=identity) - assert isinstance(result.response, CreateMediaBuySuccess), ( - f"Errors: {getattr(result.response, 'errors', None)}" - ) + assert isinstance(result.response, CreateMediaBuySuccess), f"Errors: {getattr(result.response, 'errors', None)}" with get_db_session() as session: packages = session.scalars( - select(DBMediaPackage).where( - DBMediaPackage.media_buy_id == result.response.media_buy_id - ) + select(DBMediaPackage).where(DBMediaPackage.media_buy_id == result.response.media_buy_id) ).all() assert packages, "At least one MediaPackage row must exist." @@ -315,25 +298,27 @@ async def test_future_dated_buy_with_creative_ids_has_pending_start_in_db( # Create a pre-approved creative so the creative_ids assignment is valid creative_id = f"cre_path05_{uuid.uuid4().hex[:8]}" with get_db_session() as session: - session.add(DBCreative( - tenant_id=tenant_id, - creative_id=creative_id, - principal_id=principal_id, - name="Pre-approved Creative", - agent_url="https://creative.adcontextprotocol.org", - format="display_300x250", - status="approved", - data={ - "assets": { - "banner_image": { - "asset_type": "image", - "url": "https://example.com/banner.png", - "width": 300, - "height": 250, + session.add( + DBCreative( + tenant_id=tenant_id, + creative_id=creative_id, + principal_id=principal_id, + name="Pre-approved Creative", + agent_url="https://creative.adcontextprotocol.org", + format="display_300x250", + status="approved", + data={ + "assets": { + "banner_image": { + "asset_type": "image", + "url": "https://example.com/banner.png", + "width": 300, + "height": 250, + } } - } - }, - )) + }, + ) + ) session.commit() tenant_dict = _get_tenant_dict(tenant_id) @@ -342,7 +327,7 @@ async def test_future_dated_buy_with_creative_ids_has_pending_start_in_db( req = CreateMediaBuyRequest( **required_request_kwargs(), brand={"domain": "testbrand.com"}, - start_time=_future(days=3), # clearly in the future + start_time=_future(days=3), # clearly in the future end_time=_future(days=10), packages=[ { @@ -355,9 +340,7 @@ async def test_future_dated_buy_with_creative_ids_has_pending_start_in_db( ) result = await _create_media_buy_impl(req=req, identity=identity) - assert isinstance(result.response, CreateMediaBuySuccess), ( - f"Errors: {getattr(result.response, 'errors', None)}" - ) + assert isinstance(result.response, CreateMediaBuySuccess), f"Errors: {getattr(result.response, 'errors', None)}" with get_db_session() as session: row = session.scalars( @@ -385,11 +368,8 @@ class TestCrossTenantIsolation: invariant in the system — a unit test with mocked DB cannot prove real SQL scoping. """ - async def test_tenant_a_buy_not_visible_to_tenant_b( - self, sample_tenant, sample_principal, sample_products - ): + async def test_tenant_a_buy_not_visible_to_tenant_b(self, sample_tenant, sample_principal, sample_products): """PATH-006: MediaBuy created under tenant A is not returned by tenant B's repository.""" - from src.core.database.models import Principal as DBPrincipal from src.core.database.models import Tenant as DBTenant from src.core.database.repositories import MediaBuyRepository from src.core.tools.media_buy_create import _create_media_buy_impl @@ -407,13 +387,18 @@ async def test_tenant_a_buy_not_visible_to_tenant_b( tenant_b_id = f"tenant_b_{uuid.uuid4().hex[:8]}" now = datetime.now(UTC) with get_db_session() as session: - session.add(DBTenant( - tenant_id=tenant_b_id, name="Tenant B", - subdomain=f"tenant-b-{uuid.uuid4().hex[:6]}", - is_active=True, ad_server="mock", - human_review_required=False, - created_at=now, updated_at=now, - )) + session.add( + DBTenant( + tenant_id=tenant_b_id, + name="Tenant B", + subdomain=f"tenant-b-{uuid.uuid4().hex[:6]}", + is_active=True, + ad_server="mock", + human_review_required=False, + created_at=now, + updated_at=now, + ) + ) session.commit() # Tenant B's repository must NOT see tenant A's buy diff --git a/tests/integration/test_create_media_buy_creative_validation.py b/tests/integration/test_create_media_buy_creative_validation.py index a36aaae6d7..57c2b8be98 100644 --- a/tests/integration/test_create_media_buy_creative_validation.py +++ b/tests/integration/test_create_media_buy_creative_validation.py @@ -31,7 +31,7 @@ # --------------------------------------------------------------------------- -def _make_package_with_creative(creative_id: str, product_id: str = "nonexistent_prod") -> "MediaPackage": # noqa: F821 +def _make_package_with_creative(creative_id: str, product_id: str = "nonexistent_prod") -> MediaPackage: # noqa: F821 """Build a minimal MediaPackage that references a creative_id. product_id is set to a non-existent value so the product format-compatibility @@ -65,35 +65,41 @@ def _create_tenant_and_creative(session, *, status: str, format_id: str = "displ creative_id = f"cre_{suffix}" now = datetime.now(UTC) - session.add(Tenant( - tenant_id=tenant_id, - name=f"Creative Val Tenant {suffix}", - subdomain=f"crea-{suffix}", - is_active=True, - ad_server="mock", - human_review_required=False, - created_at=now, - updated_at=now, - )) - session.add(Principal( - tenant_id=tenant_id, - principal_id=principal_id, - name="Test Agent", - access_token=f"tok_{suffix}", - platform_mappings={"mock": {"id": "adv_test"}}, # non-empty: PlatformMappingModel requires it - created_at=now, - )) + session.add( + Tenant( + tenant_id=tenant_id, + name=f"Creative Val Tenant {suffix}", + subdomain=f"crea-{suffix}", + is_active=True, + ad_server="mock", + human_review_required=False, + created_at=now, + updated_at=now, + ) + ) + session.add( + Principal( + tenant_id=tenant_id, + principal_id=principal_id, + name="Test Agent", + access_token=f"tok_{suffix}", + platform_mappings={"mock": {"id": "adv_test"}}, # non-empty: PlatformMappingModel requires it + created_at=now, + ) + ) session.flush() # satisfy FK before inserting Creative - session.add(Creative( - tenant_id=tenant_id, - creative_id=creative_id, - principal_id=principal_id, - name=f"Test Creative {suffix}", - agent_url="https://creative.adcontextprotocol.org", - format=format_id, - status=status, - data={"assets": {"banner": {"url": "https://example.com/banner.png"}}}, - )) + session.add( + Creative( + tenant_id=tenant_id, + creative_id=creative_id, + principal_id=principal_id, + name=f"Test Creative {suffix}", + agent_url="https://creative.adcontextprotocol.org", + format=format_id, + status=status, + data={"assets": {"banner": {"url": "https://example.com/banner.png"}}}, + ) + ) session.commit() return tenant_id, creative_id @@ -128,12 +134,8 @@ def test_error_status_creative_raises_invalid_creatives(self, integration_db): _validate_creatives_before_adapter_call([package], tenant_id, session=session) error_text = str(exc_info.value) - assert "error" in error_text.lower(), ( - "Error message must mention the creative's terminal status." - ) - assert creative_id in error_text, ( - "Error message must name the offending creative_id so buyers can identify it." - ) + assert "error" in error_text.lower(), "Error message must mention the creative's terminal status." + assert creative_id in error_text, "Error message must name the offending creative_id so buyers can identify it." details = exc_info.value.details or {} assert details.get("error_code") == "INVALID_CREATIVES", ( "error_code must be INVALID_CREATIVES for adapter to map it correctly." @@ -207,37 +209,54 @@ def test_format_mismatch_between_creative_and_product_raises_error(self, integra now = datetime.now(UTC) with get_db_session() as session: - session.add(Tenant( - tenant_id=tenant_id, name=f"Fmt Mismatch {suffix}", - subdomain=f"fmt-{suffix}", is_active=True, ad_server="mock", - human_review_required=False, created_at=now, updated_at=now, - )) - session.add(Principal( - tenant_id=tenant_id, principal_id=principal_id, - name="Agent", access_token=f"tok_{suffix}", - platform_mappings={"mock": {"id": "adv_test"}}, - created_at=now, - )) + session.add( + Tenant( + tenant_id=tenant_id, + name=f"Fmt Mismatch {suffix}", + subdomain=f"fmt-{suffix}", + is_active=True, + ad_server="mock", + human_review_required=False, + created_at=now, + updated_at=now, + ) + ) + session.add( + Principal( + tenant_id=tenant_id, + principal_id=principal_id, + name="Agent", + access_token=f"tok_{suffix}", + platform_mappings={"mock": {"id": "adv_test"}}, + created_at=now, + ) + ) session.flush() # satisfy FK before inserting Creative # Creative uses display format - session.add(Creative( - tenant_id=tenant_id, creative_id=creative_id, - principal_id=principal_id, name="Display Creative", - agent_url="https://creative.adcontextprotocol.org", - format="display_300x250", # ← display format - status="pending", - data={"assets": {"banner": {"url": "https://example.com/banner.png", "width": 300, "height": 250}}}, - )) + session.add( + Creative( + tenant_id=tenant_id, + creative_id=creative_id, + principal_id=principal_id, + name="Display Creative", + agent_url="https://creative.adcontextprotocol.org", + format="display_300x250", # ← display format + status="pending", + data={"assets": {"banner": {"url": "https://example.com/banner.png", "width": 300, "height": 250}}}, + ) + ) # Product ONLY accepts video — intentional mismatch - session.add(Product( - tenant_id=tenant_id, product_id=product_id, - name="Video Product", delivery_type="guaranteed", - targeting_template={}, - format_ids=[ - {"agent_url": "https://creative.adcontextprotocol.org", "id": "video_15s"} - ], - property_tags=["all_inventory"], - )) + session.add( + Product( + tenant_id=tenant_id, + product_id=product_id, + name="Video Product", + delivery_type="guaranteed", + targeting_template={}, + format_ids=[{"agent_url": "https://creative.adcontextprotocol.org", "id": "video_15s"}], + property_tags=["all_inventory"], + ) + ) session.commit() # Package references the display creative but targets the video product diff --git a/tests/integration/test_create_media_buy_integration.py b/tests/integration/test_create_media_buy_integration.py index 87b9d8016e..86f1fad49a 100644 --- a/tests/integration/test_create_media_buy_integration.py +++ b/tests/integration/test_create_media_buy_integration.py @@ -61,7 +61,7 @@ def _make_keyed_request(idempotency_key: str, **overrides) -> CreateMediaBuyRequ ) -def _identity_for(sample_tenant: dict, principal_id: str, *, bypass_setup: bool = False) -> "ResolvedIdentity": # noqa: F821 +def _identity_for(sample_tenant: dict, principal_id: str, *, bypass_setup: bool = False) -> ResolvedIdentity: # noqa: F821 """Build a ResolvedIdentity from the sample_tenant fixture dict.""" tenant_dict = _get_tenant_dict(sample_tenant["tenant_id"]) return make_lifecycle_identity( @@ -84,9 +84,7 @@ class TestIdempotency: actual enforcement mechanism and that the replay path returns the correct data. """ - async def test_first_create_with_idempotency_key_succeeds( - self, sample_tenant, sample_principal, sample_products - ): + async def test_first_create_with_idempotency_key_succeeds(self, sample_tenant, sample_principal, sample_products): """TC-IDEM-001: first request with a fresh idempotency_key creates a new media buy. WHY THIS TEST EXISTS: @@ -105,15 +103,12 @@ async def test_first_create_with_idempotency_key_succeeds( result = await _create_media_buy_impl(req=req, identity=identity) assert not isinstance(result.response, CreateMediaBuyError), ( - f"First create with fresh key must succeed. Errors: " - f"{getattr(result.response, 'errors', None)}" + f"First create with fresh key must succeed. Errors: {getattr(result.response, 'errors', None)}" ) assert isinstance(result.response, CreateMediaBuySuccess) assert result.response.media_buy_id, "Response must contain a media_buy_id." - async def test_retry_with_same_key_returns_original_buy( - self, sample_tenant, sample_principal, sample_products - ): + async def test_retry_with_same_key_returns_original_buy(self, sample_tenant, sample_principal, sample_products): """TC-IDEM-002: second request with the same idempotency_key returns the same media_buy_id. WHY THIS TEST EXISTS: @@ -152,9 +147,7 @@ async def test_retry_with_same_key_returns_original_buy( f"not a new one {result2.response.media_buy_id!r}." ) - async def test_same_key_different_principal_creates_new_buy( - self, sample_tenant, sample_principal, sample_products - ): + async def test_same_key_different_principal_creates_new_buy(self, sample_tenant, sample_principal, sample_products): """TC-IDEM-003: same idempotency_key used by a different principal creates a new buy. WHY THIS TEST EXISTS: @@ -214,9 +207,7 @@ async def test_same_key_different_principal_creates_new_buy( class TestProductValidation: """Product lookup hits the real DB — the repository returns None for missing products.""" - async def test_unknown_product_id_raises_product_not_found( - self, sample_tenant, sample_principal, sample_products - ): + async def test_unknown_product_id_raises_product_not_found(self, sample_tenant, sample_principal, sample_products): """TC-PROD-004: product_id not present in the DB → AdCPProductNotFoundError. WHY THIS TEST EXISTS: @@ -283,8 +274,8 @@ async def test_unsupported_currency_returns_descriptive_error(self, integration_ does not have all the required setup artifacts (GAMInventory, TenantAuthConfig, AuthorizedProperty etc.) — only the currency check is under test here. """ - from src.core.database.models import Principal, Product, Tenant from src.core.database.models import PricingOption as PricingOptionModel + from src.core.database.models import Principal, Product, Tenant from src.core.testing_hooks import AdCPTestContext from src.core.tools.media_buy_create import _create_media_buy_impl @@ -295,24 +286,28 @@ async def test_unsupported_currency_returns_descriptive_error(self, integration_ # Minimal tenant — no CurrencyLimit rows (the condition under test). with get_db_session() as session: now = datetime.now(UTC) - session.add(Tenant( - tenant_id=tenant_id, - name=f"No-Currency Tenant {suffix}", - subdomain=f"no-curr-{suffix}", - is_active=True, - ad_server="mock", - human_review_required=False, - created_at=now, - updated_at=now, - )) - session.add(Principal( - tenant_id=tenant_id, - principal_id=principal_id, - name="Test Agent", - access_token=f"tok_{suffix}", - platform_mappings={"mock": {"id": "adv_test"}}, - created_at=now, - )) + session.add( + Tenant( + tenant_id=tenant_id, + name=f"No-Currency Tenant {suffix}", + subdomain=f"no-curr-{suffix}", + is_active=True, + ad_server="mock", + human_review_required=False, + created_at=now, + updated_at=now, + ) + ) + session.add( + Principal( + tenant_id=tenant_id, + principal_id=principal_id, + name="Test Agent", + access_token=f"tok_{suffix}", + platform_mappings={"mock": {"id": "adv_test"}}, + created_at=now, + ) + ) product = Product( tenant_id=tenant_id, product_id="display_usd", @@ -325,14 +320,16 @@ async def test_unsupported_currency_returns_descriptive_error(self, integration_ session.add(product) session.commit() # Add a USD pricing option to the product. - session.add(PricingOptionModel( - product_id="display_usd", - tenant_id=tenant_id, - pricing_model="cpm", - currency="USD", - rate=5.0, - is_fixed=True, - )) + session.add( + PricingOptionModel( + product_id="display_usd", + tenant_id=tenant_id, + pricing_model="cpm", + currency="USD", + rate=5.0, + is_fixed=True, + ) + ) session.commit() tenant_dict = _get_tenant_dict(tenant_id) @@ -355,9 +352,7 @@ async def test_unsupported_currency_returns_descriptive_error(self, integration_ brand={"domain": "testbrand.com"}, start_time=_future(1), end_time=_future(8), - packages=[ - {"product_id": "display_usd", "budget": 5000.0, "pricing_option_id": "cpm_usd_fixed"} - ], + packages=[{"product_id": "display_usd", "budget": 5000.0, "pricing_option_id": "cpm_usd_fixed"}], ) result = await _create_media_buy_impl(req=req, identity=identity) @@ -384,9 +379,7 @@ class TestDatabasePersistence: read the rows back to confirm the data was actually committed. """ - async def test_successful_creation_persists_media_buy_row( - self, sample_tenant, sample_principal, sample_products - ): + async def test_successful_creation_persists_media_buy_row(self, sample_tenant, sample_principal, sample_products): """TC-DB-001: CreateMediaBuySuccess → MediaBuy row with correct fields in DB. WHY THIS TEST EXISTS: @@ -405,22 +398,15 @@ async def test_successful_creation_persists_media_buy_row( result = await _create_media_buy_impl(req=req, identity=identity) assert isinstance(result.response, CreateMediaBuySuccess), ( - f"Expected success to verify persistence. Errors: " - f"{getattr(result.response, 'errors', None)}" + f"Expected success to verify persistence. Errors: {getattr(result.response, 'errors', None)}" ) media_buy_id = result.response.media_buy_id with get_db_session() as session: - row = session.scalars( - select(DBMediaBuy).where(DBMediaBuy.media_buy_id == media_buy_id) - ).first() + row = session.scalars(select(DBMediaBuy).where(DBMediaBuy.media_buy_id == media_buy_id)).first() - assert row is not None, ( - f"MediaBuy row {media_buy_id!r} not found in DB after successful create." - ) - assert row.tenant_id == sample_tenant["tenant_id"], ( - "Persisted tenant_id must match the requesting tenant." - ) + assert row is not None, f"MediaBuy row {media_buy_id!r} not found in DB after successful create." + assert row.tenant_id == sample_tenant["tenant_id"], "Persisted tenant_id must match the requesting tenant." assert row.principal_id == sample_principal["principal_id"], ( "Persisted principal_id must match the requesting principal." ) @@ -452,20 +438,15 @@ async def test_successful_creation_persists_one_package_row_per_requested_packag result = await _create_media_buy_impl(req=req, identity=identity) assert isinstance(result.response, CreateMediaBuySuccess), ( - f"Expected success to verify package persistence. Errors: " - f"{getattr(result.response, 'errors', None)}" + f"Expected success to verify package persistence. Errors: {getattr(result.response, 'errors', None)}" ) media_buy_id = result.response.media_buy_id with get_db_session() as session: - packages = session.scalars( - select(DBMediaPackage).where(DBMediaPackage.media_buy_id == media_buy_id) - ).all() + packages = session.scalars(select(DBMediaPackage).where(DBMediaPackage.media_buy_id == media_buy_id)).all() assert len(packages) == 1, ( f"Expected 1 MediaPackage row for 1 requested package, " f"got {len(packages)} for media_buy_id={media_buy_id!r}." ) - assert packages[0].media_buy_id == media_buy_id, ( - "MediaPackage.media_buy_id FK must match the created MediaBuy." - ) \ No newline at end of file + assert packages[0].media_buy_id == media_buy_id, "MediaPackage.media_buy_id FK must match the created MediaBuy." diff --git a/tests/integration/test_create_media_buy_persistence_details.py b/tests/integration/test_create_media_buy_persistence_details.py index 39b42e9f39..95e8797ec2 100644 --- a/tests/integration/test_create_media_buy_persistence_details.py +++ b/tests/integration/test_create_media_buy_persistence_details.py @@ -61,8 +61,7 @@ async def test_workflow_mapping_row_created_after_successful_buy( result = await _create_media_buy_impl(req=req, identity=identity) assert isinstance(result.response, CreateMediaBuySuccess), ( - f"Expected success to verify workflow mapping. " - f"Errors: {getattr(result.response, 'errors', None)}" + f"Expected success to verify workflow mapping. Errors: {getattr(result.response, 'errors', None)}" ) media_buy_id = result.response.media_buy_id @@ -104,9 +103,7 @@ class TestRawRequestPersisted: back from the DB and verifies the critical fields are present and correct. """ - async def test_raw_request_stored_with_package_and_brand( - self, sample_tenant, sample_principal, sample_products - ): + async def test_raw_request_stored_with_package_and_brand(self, sample_tenant, sample_principal, sample_products): """TC-DB-005: MediaBuy.raw_request contains brand, packages, and idempotency_key.""" from src.core.database.models import MediaBuy as DBMediaBuy from src.core.tools.media_buy_create import _create_media_buy_impl @@ -118,28 +115,19 @@ async def test_raw_request_stored_with_package_and_brand( result = await _create_media_buy_impl(req=req, identity=identity) assert isinstance(result.response, CreateMediaBuySuccess), ( - f"Expected success to verify raw_request. " - f"Errors: {getattr(result.response, 'errors', None)}" + f"Expected success to verify raw_request. Errors: {getattr(result.response, 'errors', None)}" ) media_buy_id = result.response.media_buy_id with get_db_session() as session: - row = session.scalars( - select(DBMediaBuy).where(DBMediaBuy.media_buy_id == media_buy_id) - ).first() + row = session.scalars(select(DBMediaBuy).where(DBMediaBuy.media_buy_id == media_buy_id)).first() assert row is not None, f"MediaBuy row not found for {media_buy_id!r}" raw = row.raw_request - assert raw is not None, ( - "raw_request must be populated — it is required for manual approval reconstruction." - ) + assert raw is not None, "raw_request must be populated — it is required for manual approval reconstruction." assert isinstance(raw, dict), "raw_request must be a dict (JSON-parsed)." - assert "brand" in raw, ( - "raw_request must contain 'brand' — used to reconstruct advertiser context on approval." - ) - assert "packages" in raw, ( - "raw_request must contain 'packages' — used to reconstruct line items on approval." - ) + assert "brand" in raw, "raw_request must contain 'brand' — used to reconstruct advertiser context on approval." + assert "packages" in raw, "raw_request must contain 'packages' — used to reconstruct line items on approval." assert "idempotency_key" in raw, ( "raw_request must contain 'idempotency_key' — needed for idempotency replay logic." ) diff --git a/tests/integration/test_error_paths.py b/tests/integration/test_error_paths.py index 8bcf48a40f..afbab3dabf 100644 --- a/tests/integration/test_error_paths.py +++ b/tests/integration/test_error_paths.py @@ -455,6 +455,7 @@ def test_error_class_is_constructible(self): def test_error_class_imported_in_main(self): """Verify Error class is imported in main.py (regression test for PR #332).""" import src.core.main + from src.core.schemas import Error # Verify Error is accessible from main module @@ -491,9 +492,9 @@ def test_rest_validation_error_has_correctable_recovery(self): """REST 400 from AdCPValidationError includes recovery='correctable'.""" from unittest.mock import patch + from src.app import app from starlette.testclient import TestClient - from src.app import app from src.core.exceptions import AdCPValidationError with patch( @@ -511,9 +512,9 @@ def test_rest_adapter_error_has_transient_recovery(self): """REST 502 from AdCPAdapterError includes recovery='transient'.""" from unittest.mock import patch + from src.app import app from starlette.testclient import TestClient - from src.app import app from src.core.exceptions import AdCPAdapterError with patch( @@ -531,9 +532,9 @@ def test_rest_custom_recovery_override_preserved(self): """Custom recovery= override is preserved through REST boundary.""" from unittest.mock import patch + from src.app import app from starlette.testclient import TestClient - from src.app import app from src.core.exceptions import AdCPNotFoundError with patch( diff --git a/tests/integration/test_list_authorized_properties_integration.py b/tests/integration/test_list_authorized_properties_integration.py index ad22110361..db9b2f1d2b 100644 --- a/tests/integration/test_list_authorized_properties_integration.py +++ b/tests/integration/test_list_authorized_properties_integration.py @@ -5,11 +5,11 @@ """ import pytest +from src.core.tools.properties import _list_authorized_properties_impl from src.core.database.database_session import get_db_session from src.core.database.models import PublisherPartner, Tenant from src.core.resolved_identity import ResolvedIdentity -from src.core.tools.properties import _list_authorized_properties_impl @pytest.mark.requires_db diff --git a/tests/unit/test_a2a_auth_optional.py b/tests/unit/test_a2a_auth_optional.py index ae28e213c6..3093bcba2d 100644 --- a/tests/unit/test_a2a_auth_optional.py +++ b/tests/unit/test_a2a_auth_optional.py @@ -13,8 +13,8 @@ import pytest from a2a.utils.errors import ServerError - from src.a2a_server.adcp_a2a_server import AdCPRequestHandler + from src.core.resolved_identity import ResolvedIdentity diff --git a/tests/unit/test_a2a_brand_manifest_parameter.py b/tests/unit/test_a2a_brand_manifest_parameter.py index 775cf97212..1721f27a9d 100644 --- a/tests/unit/test_a2a_brand_manifest_parameter.py +++ b/tests/unit/test_a2a_brand_manifest_parameter.py @@ -13,8 +13,8 @@ from unittest.mock import MagicMock, patch import pytest - from src.a2a_server.adcp_a2a_server import AdCPRequestHandler + from src.core.resolved_identity import ResolvedIdentity logger = logging.getLogger(__name__) diff --git a/tests/unit/test_a2a_call_context_builder.py b/tests/unit/test_a2a_call_context_builder.py index 1e7f568ca7..87563dca4e 100644 --- a/tests/unit/test_a2a_call_context_builder.py +++ b/tests/unit/test_a2a_call_context_builder.py @@ -27,7 +27,6 @@ def test_builder_class_exists(self): def test_builder_inherits_call_context_builder(self): """AdCPCallContextBuilder must inherit from SDK's CallContextBuilder.""" from a2a.server.apps.jsonrpc.jsonrpc_app import CallContextBuilder - from src.a2a_server.context_builder import AdCPCallContextBuilder assert issubclass(AdCPCallContextBuilder, CallContextBuilder) diff --git a/tests/unit/test_a2a_function_call_validation.py b/tests/unit/test_a2a_function_call_validation.py index 2734f2a4e2..2ff9aa2637 100644 --- a/tests/unit/test_a2a_function_call_validation.py +++ b/tests/unit/test_a2a_function_call_validation.py @@ -203,6 +203,7 @@ def test_core_function_can_be_called_with_mock_context(self): # Note: Signals tools removed - now testing get_products instead from src.a2a_server.adcp_a2a_server import core_get_products_tool + from src.core.schemas import GetProductsRequest from src.core.tool_context import ToolContext diff --git a/tests/unit/test_a2a_handler_correctness.py b/tests/unit/test_a2a_handler_correctness.py index 32e2af29f9..f209e0de96 100644 --- a/tests/unit/test_a2a_handler_correctness.py +++ b/tests/unit/test_a2a_handler_correctness.py @@ -84,6 +84,7 @@ class TestStubSkillsRaiseErrors: async def test_approve_creative_raises_error(self): """approve_creative must raise ServerError(UnsupportedOperationError).""" from src.a2a_server.adcp_a2a_server import AdCPRequestHandler + from src.core.resolved_identity import ResolvedIdentity handler = AdCPRequestHandler() @@ -95,6 +96,7 @@ async def test_approve_creative_raises_error(self): async def test_get_media_buy_status_raises_error(self): """get_media_buy_status must raise ServerError(UnsupportedOperationError).""" from src.a2a_server.adcp_a2a_server import AdCPRequestHandler + from src.core.resolved_identity import ResolvedIdentity handler = AdCPRequestHandler() @@ -106,6 +108,7 @@ async def test_get_media_buy_status_raises_error(self): async def test_optimize_media_buy_raises_error(self): """optimize_media_buy must raise ServerError(UnsupportedOperationError).""" from src.a2a_server.adcp_a2a_server import AdCPRequestHandler + from src.core.resolved_identity import ResolvedIdentity handler = AdCPRequestHandler() diff --git a/tests/unit/test_a2a_nl_auth_redundancy.py b/tests/unit/test_a2a_nl_auth_redundancy.py index 56e1472b52..26e8902820 100644 --- a/tests/unit/test_a2a_nl_auth_redundancy.py +++ b/tests/unit/test_a2a_nl_auth_redundancy.py @@ -12,8 +12,8 @@ from unittest.mock import MagicMock, patch import pytest - from src.a2a_server.adcp_a2a_server import AdCPRequestHandler + from src.core.resolved_identity import ResolvedIdentity from tests.a2a_helpers import make_a2a_context diff --git a/tests/unit/test_a2a_tenant_detection_order.py b/tests/unit/test_a2a_tenant_detection_order.py index a218038f85..69c9d6779e 100644 --- a/tests/unit/test_a2a_tenant_detection_order.py +++ b/tests/unit/test_a2a_tenant_detection_order.py @@ -76,6 +76,7 @@ def test_a2a_delegates_to_resolve_identity(self, mock_resolve): inline tenant detection, resolve_identity() would NOT be called. """ from src.a2a_server.adcp_a2a_server import AdCPRequestHandler + from src.core.resolved_identity import ResolvedIdentity from tests.a2a_helpers import make_a2a_context diff --git a/tests/unit/test_a2a_testing_context_extraction.py b/tests/unit/test_a2a_testing_context_extraction.py index 8e92586bea..1f8ce0c837 100644 --- a/tests/unit/test_a2a_testing_context_extraction.py +++ b/tests/unit/test_a2a_testing_context_extraction.py @@ -12,6 +12,7 @@ from unittest.mock import patch from src.a2a_server.adcp_a2a_server import AdCPRequestHandler + from src.core.resolved_identity import ResolvedIdentity from tests.a2a_helpers import make_a2a_context diff --git a/tests/unit/test_a2a_transport_contract.py b/tests/unit/test_a2a_transport_contract.py index 22a33bb650..4071d3c5aa 100644 --- a/tests/unit/test_a2a_transport_contract.py +++ b/tests/unit/test_a2a_transport_contract.py @@ -17,9 +17,9 @@ from unittest.mock import patch import pytest +from src.app import app from starlette.testclient import TestClient -from src.app import app from src.core.resolved_identity import ResolvedIdentity _MOCK_IDENTITY = ResolvedIdentity( diff --git a/tests/unit/test_adapter_packages_fix.py b/tests/unit/test_adapter_packages_fix.py index 19a288d3ad..18987eb4f7 100644 --- a/tests/unit/test_adapter_packages_fix.py +++ b/tests/unit/test_adapter_packages_fix.py @@ -9,10 +9,10 @@ from unittest.mock import Mock, patch import pytest - from src.adapters.kevel import Kevel from src.adapters.triton_digital import TritonDigital from src.adapters.xandr import XandrAdapter + from src.core.schemas import CreateMediaBuyRequest, FormatId, MediaPackage diff --git a/tests/unit/test_adcp_exceptions.py b/tests/unit/test_adcp_exceptions.py index 94443c0808..9cfd6f5112 100644 --- a/tests/unit/test_adcp_exceptions.py +++ b/tests/unit/test_adcp_exceptions.py @@ -261,6 +261,7 @@ class TestFastAPIExceptionHandlers: def test_validation_error_returns_400(self): """AdCPValidationError raised in a route must return 400.""" from src.app import app + from src.core.exceptions import AdCPValidationError # Add a temporary test route that raises @@ -278,6 +279,7 @@ def raise_validation(): def test_authentication_error_returns_401(self): """AdCPAuthenticationError raised in a route must return 401.""" from src.app import app + from src.core.exceptions import AdCPAuthenticationError @app.get("/test-exc/auth") @@ -293,6 +295,7 @@ def raise_auth(): def test_not_found_error_returns_404(self): """AdCPNotFoundError raised in a route must return 404.""" from src.app import app + from src.core.exceptions import AdCPNotFoundError @app.get("/test-exc/notfound") @@ -308,6 +311,7 @@ def raise_not_found(): def test_adapter_error_returns_502(self): """AdCPAdapterError raised in a route must return 502.""" from src.app import app + from src.core.exceptions import AdCPAdapterError @app.get("/test-exc/adapter") @@ -323,6 +327,7 @@ def raise_adapter(): def test_conflict_error_returns_409(self): """AdCPConflictError raised in a route must return 409.""" from src.app import app + from src.core.exceptions import AdCPConflictError @app.get("/test-exc/conflict") @@ -338,6 +343,7 @@ def raise_conflict(): def test_gone_error_returns_410(self): """AdCPGoneError raised in a route must return 410.""" from src.app import app + from src.core.exceptions import AdCPGoneError @app.get("/test-exc/gone") @@ -353,6 +359,7 @@ def raise_gone(): def test_budget_exhausted_error_returns_422(self): """AdCPBudgetExhaustedError raised in a route must return 422.""" from src.app import app + from src.core.exceptions import AdCPBudgetExhaustedError @app.get("/test-exc/budget") @@ -368,6 +375,7 @@ def raise_budget(): def test_service_unavailable_error_returns_503(self): """AdCPServiceUnavailableError raised in a route must return 503.""" from src.app import app + from src.core.exceptions import AdCPServiceUnavailableError @app.get("/test-exc/unavailable") @@ -383,6 +391,7 @@ def raise_unavailable(): def test_error_response_has_standard_envelope(self): """Error responses must have {error_code, message, details} envelope.""" from src.app import app + from src.core.exceptions import AdCPValidationError @app.get("/test-exc/envelope") diff --git a/tests/unit/test_auth_context.py b/tests/unit/test_auth_context.py index 2c82e0cec8..64deee24d1 100644 --- a/tests/unit/test_auth_context.py +++ b/tests/unit/test_auth_context.py @@ -64,6 +64,7 @@ class TestAuthContextMiddleware: def test_bearer_token_extracted(self): """Middleware extracts token from Authorization: Bearer header.""" from src.app import app + from src.core.auth_context import get_auth_context @app.get("/test-auth/bearer-check") @@ -81,6 +82,7 @@ def check_bearer(auth_ctx=get_auth_context): def test_adcp_auth_header_extracted(self): """Middleware extracts token from x-adcp-auth header.""" from src.app import app + from src.core.auth_context import get_auth_context @app.get("/test-auth/adcp-check") @@ -98,6 +100,7 @@ def check_adcp(auth_ctx=get_auth_context): def test_no_auth_gives_none_token(self): """Requests without auth headers get AuthContext with auth_token=None.""" from src.app import app + from src.core.auth_context import get_auth_context @app.get("/test-auth/noauth-check-v2") @@ -112,6 +115,7 @@ def check_noauth(auth_ctx=get_auth_context): def test_headers_captured_in_context(self): """Middleware captures request headers in AuthContext.""" from src.app import app + from src.core.auth_context import get_auth_context @app.get("/test-auth/headers-check") diff --git a/tests/unit/test_authorized_properties_behavioral.py b/tests/unit/test_authorized_properties_behavioral.py index eb9f6ded1e..1b9d712287 100644 --- a/tests/unit/test_authorized_properties_behavioral.py +++ b/tests/unit/test_authorized_properties_behavioral.py @@ -465,7 +465,6 @@ async def test_resolves_identity_from_context(self): from unittest.mock import AsyncMock from fastmcp.server.context import Context - from src.core.tools.properties import list_authorized_properties mock_ctx = MagicMock(spec=Context) @@ -498,7 +497,6 @@ async def test_passes_none_identity_when_no_ctx(self): async def test_wrapper_returns_tool_result(self): """H7: MCP wrapper returns a ToolResult with structured_content.""" from fastmcp.tools.tool import ToolResult - from src.core.tools.properties import list_authorized_properties with patch("src.core.tools.properties._list_authorized_properties_impl") as mock_impl: @@ -537,7 +535,6 @@ async def test_mcp_wrapper_propagates_context_to_impl(self): from unittest.mock import AsyncMock from fastmcp.server.context import Context - from src.core.tools.properties import list_authorized_properties test_context = ContextObject(e2e="list_authorized_properties", session="test-456") @@ -570,7 +567,6 @@ async def test_mcp_wrapper_context_with_existing_req(self): from unittest.mock import AsyncMock from fastmcp.server.context import Context - from src.core.tools.properties import list_authorized_properties # req has no context, but wrapper receives context as separate param diff --git a/tests/unit/test_create_media_buy_identity_guards.py b/tests/unit/test_create_media_buy_identity_guards.py index 52de4f4a38..5c38812ae9 100644 --- a/tests/unit/test_create_media_buy_identity_guards.py +++ b/tests/unit/test_create_media_buy_identity_guards.py @@ -28,7 +28,6 @@ from src.core.testing_hooks import AdCPTestContext from tests.factories.spec_required_kwargs import required_request_kwargs - # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- @@ -211,4 +210,4 @@ async def test_empty_dict_tenant_raises_authentication_error(self): req = _minimal_valid_request() with pytest.raises(AdCPAuthenticationError): - await _create_media_buy_impl(req=req, identity=identity) \ No newline at end of file + await _create_media_buy_impl(req=req, identity=identity) diff --git a/tests/unit/test_create_media_buy_misc_guards.py b/tests/unit/test_create_media_buy_misc_guards.py index bfdb749c2c..b9c783d840 100644 --- a/tests/unit/test_create_media_buy_misc_guards.py +++ b/tests/unit/test_create_media_buy_misc_guards.py @@ -55,9 +55,7 @@ def test_springserve_non_tag_demand_class_is_not_tag_mode(self): adapter.adapter_name = "springserve" adapter.demand_class = "standard" # not "tag" - assert not _is_springserve_tag_mode(adapter), ( - "SpringServe with demand_class='standard' must NOT be tag mode." - ) + assert not _is_springserve_tag_mode(adapter), "SpringServe with demand_class='standard' must NOT be tag mode." def test_non_springserve_adapter_is_not_tag_mode(self): """TC-SS-008a: GAM adapter → not tag mode, _is_springserve_tag_mode returns False. @@ -99,12 +97,8 @@ def test_non_tag_springserve_returns_packages_unchanged(self): result = _prepare_springserve_tag_mode_packages(adapter, packages, "tenant_1", mock_session) - assert result is packages, ( - "Non-tag SpringServe must return the SAME packages list object unchanged." - ) - mock_session.scalars.assert_not_called(), ( - "No DB query must be made when adapter is not in tag mode." - ) + assert result is packages, "Non-tag SpringServe must return the SAME packages list object unchanged." + mock_session.scalars.assert_not_called(), ("No DB query must be made when adapter is not in tag mode.") def test_non_springserve_adapter_returns_packages_unchanged(self): """TC-SS-008: _prepare_springserve_tag_mode_packages with GAM adapter → same list. @@ -149,8 +143,8 @@ async def test_hourly_reporting_frequency_accepted_with_warning(self, caplog): """TC-MCP-004: reporting_webhook.reporting_frequency='hourly' → accepted (no error).""" import logging - from src.core.tools.media_buy_create import _create_media_buy_impl from src.core.exceptions import AdCPProductNotFoundError + from src.core.tools.media_buy_create import _create_media_buy_impl req = CreateMediaBuyRequest( **required_request_kwargs(), @@ -194,8 +188,10 @@ async def test_hourly_reporting_frequency_accepted_with_warning(self, caplog): patch("src.core.tools.media_buy_create.get_principal_object", return_value=mock_principal), patch("src.core.database.repositories.MediaBuyUoW", return_value=mock_uow), patch("src.core.tools.media_buy_create.get_context_manager", return_value=mock_ctx_mgr), - patch("src.core.tools.media_buy_create.sandbox_mode_for_request", - return_value=MagicMock(active=False, diagnostic="")), + patch( + "src.core.tools.media_buy_create.sandbox_mode_for_request", + return_value=MagicMock(active=False, diagnostic=""), + ), caplog.at_level(logging.WARNING, logger="src.core.tools.media_buy_create"), ): # Function proceeds past the webhook frequency check and fails later @@ -206,8 +202,10 @@ async def test_hourly_reporting_frequency_accepted_with_warning(self, caplog): pass # expected: product not found in mock DB # The function must log a warning about unsupported frequency, NOT raise. - frequency_warnings = [r for r in caplog.records if "hourly" in r.message.lower() or "frequency" in r.message.lower()] + frequency_warnings = [ + r for r in caplog.records if "hourly" in r.message.lower() or "frequency" in r.message.lower() + ] assert frequency_warnings, ( "A warning must be logged for unsupported 'hourly' reporting frequency. " "The warning keeps buyers informed without blocking their request." - ) \ No newline at end of file + ) diff --git a/tests/unit/test_create_media_buy_overbook_warnings.py b/tests/unit/test_create_media_buy_overbook_warnings.py index 64fbd8236b..b53e3202c9 100644 --- a/tests/unit/test_create_media_buy_overbook_warnings.py +++ b/tests/unit/test_create_media_buy_overbook_warnings.py @@ -27,9 +27,6 @@ from datetime import UTC, datetime, timedelta from unittest.mock import MagicMock, patch -import pytest - - # --------------------------------------------------------------------------- # Adapter stub helpers # --------------------------------------------------------------------------- @@ -46,6 +43,7 @@ def _gam_adapter( first guard. A named class is required because MagicMock's class name doesn't match the string literal the code checks against. """ + class GoogleAdManager: pass @@ -56,6 +54,7 @@ class GoogleAdManager: def _non_gam_adapter() -> object: """Adapter stub with a class name that is NOT 'GoogleAdManager'.""" + class MockAdapter: pass @@ -100,7 +99,6 @@ def _mock_request(start_days: int = 1, end_days: int = 8) -> MagicMock: class TestOverbookGuards: - def test_non_gam_adapter_skips_check(self): """TC-GAMW-004: adapter is not GoogleAdManager → [] without any forecast call. diff --git a/tests/unit/test_create_media_buy_pure_functions.py b/tests/unit/test_create_media_buy_pure_functions.py index 91ac6c367e..9a43c7a04f 100644 --- a/tests/unit/test_create_media_buy_pure_functions.py +++ b/tests/unit/test_create_media_buy_pure_functions.py @@ -23,7 +23,6 @@ from src.core.exceptions import AdCPTermsRejectedError - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -300,8 +299,7 @@ def test_same_id_in_both_creative_ids_and_assignments_uses_assignment_entry(self assert len(result) == 1, "Duplicate ID must be deduplicated to a single entry." assert result[0]["weight"] == 30, ( - "creative_assignments entry (weight=30) must win over the creative_ids " - "default (weight=100)." + "creative_assignments entry (weight=30) must win over the creative_ids default (weight=100)." ) def test_empty_creative_ids_list_returns_empty_list(self): @@ -320,4 +318,4 @@ def test_empty_creative_ids_list_returns_empty_list(self): result = _get_requested_creative_assignments(pkg) assert result == [], "empty creative_ids must normalise to an empty list, not None." - assert isinstance(result, list), "Return type must always be list, never None." \ No newline at end of file + assert isinstance(result, list), "Return type must always be list, never None." diff --git a/tests/unit/test_create_media_buy_request_validation.py b/tests/unit/test_create_media_buy_request_validation.py index 20bf379c81..a1fda98d3f 100644 --- a/tests/unit/test_create_media_buy_request_validation.py +++ b/tests/unit/test_create_media_buy_request_validation.py @@ -39,7 +39,6 @@ from src.core.testing_hooks import AdCPTestContext from tests.factories.spec_required_kwargs import required_request_kwargs - # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- @@ -61,7 +60,8 @@ def _make_identity(*, tenant: dict | None = None) -> ResolvedIdentity: return ResolvedIdentity( principal_id="principal_1", tenant_id="test_tenant", - tenant=tenant or { + tenant=tenant + or { "tenant_id": "test_tenant", "human_review_required": False, "auto_create_media_buys": True, @@ -344,9 +344,7 @@ async def test_duplicate_product_id_returns_descriptive_error(self): assert "Duplicate product_id" in error_message, ( f"Error must name the problem as a duplicate. Got: {error_message!r}" ) - assert "prod_video" in error_message, ( - "Error must name the offending product_id so the buyer can fix it." - ) + assert "prod_video" in error_message, "Error must name the offending product_id so the buyer can fix it." # =========================================================================== @@ -382,11 +380,7 @@ async def test_zero_budget_non_sandbox_returns_invalid_budget_error(self): """ from src.core.tools.media_buy_create import _create_media_buy_impl - req = _make_request( - packages=[ - {"product_id": "prod_1", "budget": 0.0, "pricing_option_id": "cpm_usd"} - ] - ) + req = _make_request(packages=[{"product_id": "prod_1", "budget": 0.0, "pricing_option_id": "cpm_usd"}]) identity = _make_identity() with ( @@ -418,11 +412,7 @@ async def test_sandbox_mode_allows_zero_budget_and_proceeds_to_product_validatio """ from src.core.tools.media_buy_create import _create_media_buy_impl - req = _make_request( - packages=[ - {"product_id": "prod_sandbox", "budget": 0.0, "pricing_option_id": "cpm_usd"} - ] - ) + req = _make_request(packages=[{"product_id": "prod_sandbox", "budget": 0.0, "pricing_option_id": "cpm_usd"}]) identity = _make_identity() with ( diff --git a/tests/unit/test_create_media_buy_setup_bypass.py b/tests/unit/test_create_media_buy_setup_bypass.py index 2b77bfeda8..c2294bd471 100644 --- a/tests/unit/test_create_media_buy_setup_bypass.py +++ b/tests/unit/test_create_media_buy_setup_bypass.py @@ -35,7 +35,6 @@ from src.core.testing_hooks import AdCPTestContext from tests.factories.spec_required_kwargs import required_request_kwargs - # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- @@ -140,9 +139,7 @@ async def test_skip_env_var_prevents_setup_check(self): except Exception: pass - mock_setup.assert_not_called(), ( - "validate_setup_complete must not be called when ADCP_SKIP_SETUP_CHECK=true." - ) + mock_setup.assert_not_called(), ("validate_setup_complete must not be called when ADCP_SKIP_SETUP_CHECK=true.") @pytest.mark.asyncio async def test_without_env_var_setup_check_is_called(self): @@ -153,9 +150,9 @@ async def test_without_env_var_setup_check_is_called(self): because setup validation was removed entirely. If setup is never called regardless of the env var, both tests would wrongly pass. """ - from src.services.setup_checklist_service import SetupIncompleteError - from src.core.tools.media_buy_create import _create_media_buy_impl from src.core.exceptions import AdCPValidationError + from src.core.tools.media_buy_create import _create_media_buy_impl + from src.services.setup_checklist_service import SetupIncompleteError identity = _production_identity(dry_run=False, test_session_id=None) req = _minimal_request() @@ -164,9 +161,12 @@ async def test_without_env_var_setup_check_is_called(self): with ( patch.dict(os.environ, {}, clear=False), # ensure env var is absent patch.dict(os.environ, {"ADCP_SKIP_SETUP_CHECK": ""}), # empty string = falsy - patch(_PATCH_SETUP, side_effect=SetupIncompleteError( - "Incomplete", missing_tasks=[{"name": "Products", "description": "Add products"}] - )), + patch( + _PATCH_SETUP, + side_effect=SetupIncompleteError( + "Incomplete", missing_tasks=[{"name": "Products", "description": "Add products"}] + ), + ), patch(_PATCH_PRINCIPAL, return_value=mock_principal), ): with pytest.raises(AdCPValidationError, match="Setup incomplete"): @@ -210,9 +210,7 @@ async def test_dry_run_prevents_setup_check(self): except Exception: pass # dry_run may short-circuit differently; we only care setup was skipped - mock_setup.assert_not_called(), ( - "validate_setup_complete must not be called when dry_run=True." - ) + mock_setup.assert_not_called(), ("validate_setup_complete must not be called when dry_run=True.") # =========================================================================== @@ -253,6 +251,4 @@ async def test_test_session_id_prevents_setup_check(self): except Exception: pass # fails at product lookup later — only care that setup was skipped - mock_setup.assert_not_called(), ( - "validate_setup_complete must not be called when test_session_id is set." - ) + mock_setup.assert_not_called(), ("validate_setup_complete must not be called when test_session_id is set.") diff --git a/tests/unit/test_direct_get_products.py b/tests/unit/test_direct_get_products.py index 706ac8a215..7d553437b4 100644 --- a/tests/unit/test_direct_get_products.py +++ b/tests/unit/test_direct_get_products.py @@ -18,6 +18,7 @@ async def test_direct_get_products(): """Test the get_products function directly.""" # Lazy imports to avoid triggering load_config() at module import time from src.core.main import get_products + from src.core.tool_context import ToolContext print("Testing direct get_products call...") diff --git a/tests/unit/test_error_boundary_translation.py b/tests/unit/test_error_boundary_translation.py index e41da5b00a..fc0191af4c 100644 --- a/tests/unit/test_error_boundary_translation.py +++ b/tests/unit/test_error_boundary_translation.py @@ -337,7 +337,6 @@ class TestA2ABoundaryAdCPErrorTranslation: async def test_adcp_validation_becomes_invalid_params(self): """AdCPValidationError → ServerError(InvalidParamsError) with correctable recovery.""" from a2a.utils.errors import ServerError - from src.a2a_server.adcp_a2a_server import AdCPRequestHandler handler = AdCPRequestHandler() @@ -360,7 +359,6 @@ async def mock_skill(params, token): async def test_adcp_auth_becomes_invalid_request(self): """AdCPAuthenticationError → ServerError(InvalidRequestError) with terminal recovery.""" from a2a.utils.errors import ServerError - from src.a2a_server.adcp_a2a_server import AdCPRequestHandler handler = AdCPRequestHandler() @@ -381,7 +379,6 @@ async def mock_skill(params, token): async def test_adcp_adapter_becomes_internal_error(self): """AdCPAdapterError → ServerError(InternalError) with transient recovery.""" from a2a.utils.errors import ServerError - from src.a2a_server.adcp_a2a_server import AdCPRequestHandler handler = AdCPRequestHandler() @@ -403,7 +400,6 @@ async def test_server_error_still_passes_through(self): """Existing ServerError behavior preserved — re-raised unchanged.""" from a2a.types import MethodNotFoundError from a2a.utils.errors import ServerError - from src.a2a_server.adcp_a2a_server import AdCPRequestHandler handler = AdCPRequestHandler() @@ -429,9 +425,8 @@ class TestRESTBoundaryAdCPErrorTranslation: def test_adcp_validation_from_impl_returns_400(self): """AdCPValidationError raised in _impl → REST returns 400 with correctable recovery.""" - from starlette.testclient import TestClient - from src.app import app + from starlette.testclient import TestClient with patch( "src.core.tools.capabilities.get_adcp_capabilities_raw", @@ -447,9 +442,8 @@ def test_adcp_validation_from_impl_returns_400(self): def test_adcp_auth_from_impl_returns_401(self): """AdCPAuthenticationError raised in _impl → REST returns 401 with terminal recovery.""" - from starlette.testclient import TestClient - from src.app import app + from starlette.testclient import TestClient with patch( "src.core.tools.capabilities.get_adcp_capabilities_raw", @@ -464,9 +458,8 @@ def test_adcp_auth_from_impl_returns_401(self): def test_adcp_not_found_from_impl_returns_404(self): """AdCPNotFoundError raised in _impl → REST returns 404 with terminal recovery.""" - from starlette.testclient import TestClient - from src.app import app + from starlette.testclient import TestClient with patch( "src.core.tools.capabilities.get_adcp_capabilities_raw", @@ -481,9 +474,8 @@ def test_adcp_not_found_from_impl_returns_404(self): def test_adcp_adapter_from_impl_returns_502(self): """AdCPAdapterError raised in _impl → REST returns 502 with transient recovery.""" - from starlette.testclient import TestClient - from src.app import app + from starlette.testclient import TestClient with patch( "src.core.tools.capabilities.get_adcp_capabilities_raw", @@ -498,9 +490,9 @@ def test_adcp_adapter_from_impl_returns_502(self): def test_adcp_conflict_from_impl_returns_409(self): """AdCPConflictError raised in _impl → REST returns 409 with correctable recovery.""" + from src.app import app from starlette.testclient import TestClient - from src.app import app from src.core.exceptions import AdCPConflictError with patch( @@ -516,9 +508,9 @@ def test_adcp_conflict_from_impl_returns_409(self): def test_adcp_service_unavailable_from_impl_returns_503(self): """AdCPServiceUnavailableError raised in _impl → REST returns 503 with transient recovery.""" + from src.app import app from starlette.testclient import TestClient - from src.app import app from src.core.exceptions import AdCPServiceUnavailableError with patch( @@ -652,8 +644,8 @@ class TestCustomRecoveryOverrideA2ABoundary: async def test_custom_recovery_propagates_through_a2a_boundary(self): """AdCPNotFoundError(recovery='transient') -> ServerError.data has 'transient'.""" from a2a.utils.errors import ServerError - from src.a2a_server.adcp_a2a_server import AdCPRequestHandler + from src.core.exceptions import AdCPNotFoundError handler = AdCPRequestHandler() @@ -674,9 +666,9 @@ class TestCustomRecoveryOverrideRESTBoundary: def test_custom_recovery_propagates_through_rest_boundary(self): """AdCPAdapterError(recovery='terminal') -> REST JSON body has 'terminal'.""" + from src.app import app from starlette.testclient import TestClient - from src.app import app from src.core.exceptions import AdCPAdapterError with patch( @@ -760,8 +752,8 @@ def failing(): async def test_a2a_roundtrip_all_subclasses(self): """All 11 AdCPError subclasses: raise -> _handle_explicit_skill -> ServerError.data.recovery.""" from a2a.utils.errors import ServerError - from src.a2a_server.adcp_a2a_server import AdCPRequestHandler + from src.core.exceptions import ( AdCPAdapterError, AdCPAuthenticationError, @@ -818,9 +810,9 @@ async def mock_skill(params, token, klass=exc_class, message=msg): def test_rest_roundtrip_all_subclasses(self): """All 11 AdCPError subclasses: raise -> REST handler -> JSON body -> verify recovery.""" + from src.app import app from starlette.testclient import TestClient - from src.app import app from src.core.exceptions import ( AdCPAdapterError, AdCPAuthenticationError, diff --git a/tests/unit/test_error_format_consistency.py b/tests/unit/test_error_format_consistency.py index 84228a1124..4497d4057f 100644 --- a/tests/unit/test_error_format_consistency.py +++ b/tests/unit/test_error_format_consistency.py @@ -16,8 +16,8 @@ from a2a.utils.errors import ServerError from fastmcp.exceptions import ToolError from pydantic import ValidationError - from src.a2a_server.adcp_a2a_server import AdCPRequestHandler + from src.core.exceptions import AdCPAuthenticationError, AdCPError, AdCPValidationError from src.core.resolved_identity import ResolvedIdentity diff --git a/tests/unit/test_fastapi_app_regression.py b/tests/unit/test_fastapi_app_regression.py index a37e8c78ff..173a505417 100644 --- a/tests/unit/test_fastapi_app_regression.py +++ b/tests/unit/test_fastapi_app_regression.py @@ -32,9 +32,8 @@ def test_receive_function_is_async(self): # Import the middleware and inspect its internals # We test this by running the middleware with a numeric messageId # and verifying the request reconstruction works. - from starlette.testclient import TestClient - from src.app import app + from starlette.testclient import TestClient client = TestClient(app) @@ -65,9 +64,8 @@ def test_receive_function_is_async(self): def test_numeric_jsonrpc_id_converted_to_string(self): """Numeric JSON-RPC id values must be converted to strings.""" - from starlette.testclient import TestClient - from src.app import app + from starlette.testclient import TestClient client = TestClient(app) @@ -97,9 +95,8 @@ def test_cors_does_not_use_wildcard_with_credentials(self): Before fix: allow_origins=["*"] + allow_credentials=True — browsers ignore. After fix: allow_origins from ALLOWED_ORIGINS env var. """ - from starlette.testclient import TestClient - from src.app import app + from starlette.testclient import TestClient client = TestClient(app) @@ -119,9 +116,8 @@ def test_cors_does_not_use_wildcard_with_credentials(self): def test_allowed_origin_gets_cors_header(self): """An origin listed in ALLOWED_ORIGINS should get CORS response header.""" - from starlette.testclient import TestClient - from src.app import app + from starlette.testclient import TestClient client = TestClient(app) @@ -186,9 +182,8 @@ def test_overly_long_hostname_rejected(self): def test_agent_card_ignores_invalid_header(self): """Agent card falls back to Host header when Apx-Incoming-Host is invalid.""" - from starlette.testclient import TestClient - from src.app import app + from starlette.testclient import TestClient client = TestClient(app) @@ -208,9 +203,8 @@ def test_agent_card_ignores_invalid_header(self): def test_agent_card_ignores_invalid_host_header(self): """Agent card falls back to default URL when Host header is invalid (salesagent-4r0m).""" - from starlette.testclient import TestClient - from src.app import app + from starlette.testclient import TestClient client = TestClient(app) @@ -239,7 +233,6 @@ class TestDebugEndpointGate: def test_require_testing_mode_blocks_in_production(self): """require_testing_mode raises 404 when ADCP_TESTING is not set.""" from fastapi import HTTPException - from src.routes.health import require_testing_mode with patch.dict(os.environ, {}, clear=True): @@ -272,9 +265,8 @@ def test_debug_endpoints_use_testing_dependency(self): def test_debug_db_state_returns_404_without_testing(self): """GET /debug/db-state returns 404 in production mode.""" - from starlette.testclient import TestClient - from src.app import app + from starlette.testclient import TestClient client = TestClient(app) @@ -329,9 +321,8 @@ class TestAdminCompatibilityMount: """Admin UI should be reachable through both /admin and the root fallback mount.""" def test_fastapi_mounts_admin_at_admin_and_root(self): - from starlette.routing import Mount - from src.app import _install_admin_mounts, app + from starlette.routing import Mount _install_admin_mounts() admin_mounts = [ @@ -350,9 +341,8 @@ def test_fastapi_mounts_admin_at_admin_and_root(self): assert "/test" not in admin_mounts def test_root_login_path_is_exposed_by_root_fallback_mount(self): - from starlette.testclient import TestClient - from src.app import _install_admin_mounts, app + from starlette.testclient import TestClient _install_admin_mounts() client = TestClient(app) @@ -360,9 +350,8 @@ def test_root_login_path_is_exposed_by_root_fallback_mount(self): assert response.status_code != 404 def test_admin_login_path_remains_available(self): - from starlette.testclient import TestClient - from src.app import _install_admin_mounts, app + from starlette.testclient import TestClient _install_admin_mounts() client = TestClient(app) @@ -390,9 +379,8 @@ class TestA2ATrailingSlashCompatibility: """A2A trailing-slash requests should stay on the FastAPI surface.""" def test_a2a_trailing_slash_redirects_to_canonical_path(self): - from starlette.testclient import TestClient - from src.app import _install_admin_mounts, app + from starlette.testclient import TestClient _install_admin_mounts() client = TestClient(app) diff --git a/tests/unit/test_mcp_auth_middleware.py b/tests/unit/test_mcp_auth_middleware.py index 40f48a0f84..a9e321d0fe 100644 --- a/tests/unit/test_mcp_auth_middleware.py +++ b/tests/unit/test_mcp_auth_middleware.py @@ -35,7 +35,6 @@ def test_module_exists(self): def test_inherits_from_middleware(self): """MCPAuthMiddleware must inherit from fastmcp.server.middleware.Middleware.""" from fastmcp.server.middleware import Middleware - from src.core.mcp_auth_middleware import MCPAuthMiddleware assert issubclass(MCPAuthMiddleware, Middleware), ( diff --git a/tests/unit/test_no_contextvar_in_a2a.py b/tests/unit/test_no_contextvar_in_a2a.py index 57b1b52f15..fa1d271ee7 100644 --- a/tests/unit/test_no_contextvar_in_a2a.py +++ b/tests/unit/test_no_contextvar_in_a2a.py @@ -41,6 +41,7 @@ def test_get_auth_token_reads_from_explicit_context(self): def test_resolve_a2a_identity_uses_context_headers_not_contextvar(self): """_resolve_a2a_identity should read headers from context, not ContextVar.""" from src.a2a_server.adcp_a2a_server import AdCPRequestHandler + from src.core.resolved_identity import ResolvedIdentity handler = AdCPRequestHandler() @@ -64,6 +65,7 @@ def test_resolve_a2a_identity_uses_context_headers_not_contextvar(self): def test_resolve_a2a_identity_uses_empty_headers_without_context(self): """_resolve_a2a_identity(context=None) should use empty headers, not ContextVar.""" from src.a2a_server.adcp_a2a_server import AdCPRequestHandler + from src.core.resolved_identity import ResolvedIdentity handler = AdCPRequestHandler() diff --git a/tests/unit/test_openapi_surface.py b/tests/unit/test_openapi_surface.py index c7e62e7a78..0c7ed71a26 100644 --- a/tests/unit/test_openapi_surface.py +++ b/tests/unit/test_openapi_surface.py @@ -9,9 +9,8 @@ beads: salesagent-b61l.16 """ -from starlette.testclient import TestClient - from src.app import app +from starlette.testclient import TestClient client = TestClient(app) diff --git a/tests/unit/test_products_transport_wrappers.py b/tests/unit/test_products_transport_wrappers.py index 129fbf1a56..4e6c7f597b 100644 --- a/tests/unit/test_products_transport_wrappers.py +++ b/tests/unit/test_products_transport_wrappers.py @@ -297,9 +297,9 @@ def test_rest_returns_json_response(self): new_callable=AsyncMock, return_value=_mock_response(), ): + from src.app import app from starlette.testclient import TestClient - from src.app import app from src.core.auth_context import _require_auth_dep, _resolve_auth_dep app.dependency_overrides[_require_auth_dep] = lambda: identity @@ -331,9 +331,9 @@ def test_rest_applies_version_compat(self): ): mock_compat.return_value = {"products": [], "legacy": True} + from src.app import app from starlette.testclient import TestClient - from src.app import app from src.core.auth_context import _require_auth_dep, _resolve_auth_dep app.dependency_overrides[_require_auth_dep] = lambda: identity diff --git a/tests/unit/test_rest_api_endpoints.py b/tests/unit/test_rest_api_endpoints.py index 128c881ae2..11c38fcf80 100644 --- a/tests/unit/test_rest_api_endpoints.py +++ b/tests/unit/test_rest_api_endpoints.py @@ -10,9 +10,9 @@ from unittest.mock import MagicMock, patch +from src.app import app from starlette.testclient import TestClient -from src.app import app from src.core.resolved_identity import ResolvedIdentity client = TestClient(app) diff --git a/tests/unit/test_rest_api_products.py b/tests/unit/test_rest_api_products.py index f802dccc08..3979b17a20 100644 --- a/tests/unit/test_rest_api_products.py +++ b/tests/unit/test_rest_api_products.py @@ -12,9 +12,9 @@ from unittest.mock import patch +from src.app import app from starlette.testclient import TestClient -from src.app import app from src.core.resolved_identity import ResolvedIdentity _MOCK_IDENTITY = ResolvedIdentity( diff --git a/tests/unit/test_task_management_auth.py b/tests/unit/test_task_management_auth.py index e6435137b7..08b30b02bd 100644 --- a/tests/unit/test_task_management_auth.py +++ b/tests/unit/test_task_management_auth.py @@ -9,10 +9,10 @@ """ import pytest +from src.core.tools.task_management import complete_task, get_task, list_tasks from src.core.exceptions import AdCPAuthenticationError from src.core.resolved_identity import ResolvedIdentity -from src.core.tools.task_management import complete_task, get_task, list_tasks def _identity_no_principal() -> ResolvedIdentity: From d7fed1293dc9d74a649187e0e40044c4dee8725b Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 16 Jul 2026 18:41:12 +0600 Subject: [PATCH 02/17] fix: remove dead code and repair latent bugs surfaced by F821/F841 audit Real bugs fixed: - edit_product crashed with NameError (undefined `formats`) when a GAM product was saved with an explicit line item type; now passes product.format_ids to generate_default_config - product suggestions computed already_exists against the first *character* of each product ID (scalars() rows are plain strings, `product[0]` sliced them); now compares full IDs - scheduled delivery webhooks computed max+1 sequence_number then dropped it: payloads never carried the AdCP sequence_number field and WebhookDeliveryLog rows always recorded 1; now assigned to the response alongside the other webhook metadata fields Dead code removed: - /api/gam/test-connection and /api/gam/get-advertisers routes: unreferenced legacy duplicates of the tenant-scoped routes in gam.py/principals.py; the former crashed with NameError (undefined oauth_client_id/tenant_id) swallowed by a catch-all except - ~25 write-only variables, a try/except that only re-raised, a redundant GAMAuthManager construction, unused imports, ambiguous `l` loop names - test_auth Test 3 mocked the legacy session.query path that the SQLAlchemy 2.0 production code never calls (and referenced an undefined mock); rewired to the scalars seam, assertion unchanged Co-Authored-By: Claude Fable 5 --- src/adapters/gam_inventory_discovery.py | 4 +- src/adapters/google_ad_manager.py | 3 +- src/adapters/mock_ad_server.py | 1 - src/admin/blueprints/api.py | 182 +----------------- src/admin/blueprints/inventory.py | 17 -- src/admin/blueprints/oidc.py | 2 +- src/admin/blueprints/products.py | 6 +- src/admin/blueprints/workflows.py | 3 - .../services/business_activity_service.py | 6 - src/admin/services/dashboard_service.py | 2 +- src/admin/tests/unit/test_auth.py | 22 +-- src/core/context_manager.py | 1 - src/core/format_resolver.py | 4 - src/core/mcp_context_wrapper.py | 2 - src/core/tools/creative_formats.py | 3 - src/core/tools/creatives/listing.py | 19 -- src/core/tools/media_buy_create.py | 16 +- src/core/tools/products.py | 13 +- src/core/tools/signals.py | 14 -- src/services/delivery_simulator.py | 4 - src/services/delivery_webhook_scheduler.py | 1 + src/services/format_metrics_service.py | 2 - 22 files changed, 22 insertions(+), 305 deletions(-) diff --git a/src/adapters/gam_inventory_discovery.py b/src/adapters/gam_inventory_discovery.py index 892a002954..844f1ce2e6 100644 --- a/src/adapters/gam_inventory_discovery.py +++ b/src/adapters/gam_inventory_discovery.py @@ -822,7 +822,7 @@ def sync_all( "with_children": len([u for u in ad_units if u.has_children]), }, "placements": {"total": len(placements), "active": len([p for p in placements if p.status == "ACTIVE"])}, - "labels": {"total": len(labels), "active": len([l for l in labels if l.is_active])}, + "labels": {"total": len(labels), "active": len([label for label in labels if label.is_active])}, "custom_targeting": { "total_keys": len(self.custom_targeting_keys), "total_values": custom_targeting.get("total_values", 0), @@ -895,7 +895,7 @@ def sync_selective( if "labels" in sync_types: self.labels.clear() labels = self.discover_labels() - summary["labels"] = {"total": len(labels), "active": len([l for l in labels if l.is_active])} + summary["labels"] = {"total": len(labels), "active": len([label for label in labels if label.is_active])} # Sync custom targeting with optional limit if "custom_targeting" in sync_types: diff --git a/src/adapters/google_ad_manager.py b/src/adapters/google_ad_manager.py index 89057d8080..ec40d60218 100644 --- a/src/adapters/google_ad_manager.py +++ b/src/adapters/google_ad_manager.py @@ -469,7 +469,7 @@ def create_media_buy( # Check if pricing model is supported by GAM adapter at all try: gam_cost_type = PricingCompatibility.get_gam_cost_type(pricing_model) - except ValueError as e: + except ValueError: error_msg = ( f"Google Ad Manager adapter does not support '{pricing_model}' pricing. " f"Supported pricing models: CPM, VCPM, CPC, FLAT_RATE. " @@ -1659,7 +1659,6 @@ def update_media_buy( # Determine if we're pausing or resuming is_pause = action.startswith("pause_") - new_status = "PAUSED" if is_pause else "READY" action_verb = "Pausing" if is_pause else "Resuming" # Package-level actions diff --git a/src/adapters/mock_ad_server.py b/src/adapters/mock_ad_server.py index 5c96592bdd..f006e8d3ce 100644 --- a/src/adapters/mock_ad_server.py +++ b/src/adapters/mock_ad_server.py @@ -1150,7 +1150,6 @@ def get_media_buy_delivery( impressions = int(spend / 0.01) # $10 CPM else: # Campaign in progress - calculate based on pacing - progress_ratio = elapsed_duration / campaign_duration daily_budget = total_budget / campaign_duration # Apply AI test scenario delivery profile if present diff --git a/src/admin/blueprints/api.py b/src/admin/blueprints/api.py index 13f22089b3..5587398650 100644 --- a/src/admin/blueprints/api.py +++ b/src/admin/blueprints/api.py @@ -7,7 +7,6 @@ from sqlalchemy import select, text from src.admin.utils import require_auth -from src.admin.utils.audit_decorator import log_admin_action from src.core.database.database_session import get_db_session from src.core.database.models import Product @@ -64,7 +63,6 @@ def oauth_status(): gam_config = get_gam_oauth_config() client_id = gam_config.client_id - client_secret = gam_config.client_secret # Log configuration check oauth_structured_logger.log_gam_oauth_config_load( @@ -76,7 +74,7 @@ def oauth_status(): { "configured": True, "client_id_prefix": client_id[:20] + "..." if len(client_id) > 20 else client_id, - "has_secret": True, + "has_secret": bool(gam_config.client_secret), "source": "validated_environment", } ) @@ -204,8 +202,7 @@ def sort_key(product): # Check existing products to mark which are already created with get_db_session() as db_session: stmt = select(Product.product_id).filter_by(tenant_id=tenant_id) - existing_products = db_session.scalars(stmt).all() - existing_ids = {product[0] for product in existing_products} + existing_ids = set(db_session.scalars(stmt).all()) # Add metadata to suggestions for suggestion in filtered_suggestions: @@ -242,178 +239,3 @@ def sort_key(product): except Exception as e: logger.error(f"Error getting product suggestions: {e}") return jsonify({"error": str(e)}), 500 - - -@api_bp.route("/gam/get-advertisers", methods=["POST"]) -@require_auth() -@log_admin_action("gam_get_advertisers") -def gam_get_advertisers(): - """TODO: Extract implementation from admin_ui.py lines 3580-3653. - GAM advertiser fetching - implement in phase 2.""" - # Placeholder implementation - return jsonify({"error": "Not yet implemented"}), 501 - - -@api_bp.route("/gam/test-connection", methods=["POST"]) -@require_auth() -@log_admin_action("test_gam_connection") -def test_gam_connection(): - """Test GAM connection with refresh token and fetch available resources.""" - try: - refresh_token = request.json.get("refresh_token") - if not refresh_token: - return jsonify({"error": "Refresh token is required"}), 400 - - # Get OAuth credentials from environment variables - import os - - client_id = os.environ.get("GAM_OAUTH_CLIENT_ID") - client_secret = os.environ.get("GAM_OAUTH_CLIENT_SECRET") - - if not client_id or not client_secret: - return ( - jsonify( - { - "error": "GAM OAuth credentials not configured. Please set GAM_OAUTH_CLIENT_ID and GAM_OAUTH_CLIENT_SECRET environment variables." - } - ), - 400, - ) - - # Test by creating credentials and making a simple API call - from googleads import ad_manager, oauth2 - - # Create GoogleAds OAuth2 client with refresh token - oauth2_client = oauth2.GoogleRefreshTokenClient( - client_id=client_id, - client_secret=client_secret, - refresh_token=refresh_token, - ) - - # Test if credentials are valid by trying to refresh - try: - # This will attempt to refresh the token - oauth2_client.Refresh() - except Exception as e: - return jsonify({"error": f"Invalid refresh token: {str(e)}"}), 400 - - # Initialize GAM client to get network info - # Note: We don't need to specify network_code for getAllNetworks call - client = ad_manager.AdManagerClient(oauth2_client, "AdCP-Sales-Agent-Setup") - - # Get network service - network_service = client.GetService("NetworkService") - - # Get all networks user has access to - try: - # Try to get all networks first - logger.info("Attempting to call getAllNetworks()") - all_networks = network_service.getAllNetworks() - logger.info(f"getAllNetworks() returned: {all_networks}") - networks = [] - if all_networks: - logger.info(f"Processing {len(all_networks)} networks") - for network in all_networks: - logger.info(f"Network data: {network}") - networks.append( - { - "id": network["id"], - "displayName": network["displayName"], - "networkCode": network["networkCode"], - } - ) - else: - logger.info("getAllNetworks() returned empty/None") - except AttributeError as e: - # getAllNetworks might not be available, fall back to getCurrentNetwork - logger.info(f"getAllNetworks not available (AttributeError: {e}), falling back to getCurrentNetwork") - try: - current_network = network_service.getCurrentNetwork() - logger.info(f"getCurrentNetwork() returned: {current_network}") - networks = [ - { - "id": current_network["id"], - "displayName": current_network["displayName"], - "networkCode": current_network["networkCode"], - } - ] - except Exception as e: - logger.error(f"Failed to get network info: {e}") - networks = [] - except Exception as e: - logger.error(f"Failed to get networks: {e}") - logger.exception("Full exception details:") - networks = [] - - result = { - "success": True, - "message": "Successfully connected to Google Ad Manager", - "networks": networks, - } - - # If we got a network, fetch companies and users - if networks: - try: - # Reinitialize client with network code for subsequent calls - network_code = networks[0]["networkCode"] - logger.info(f"Reinitializing client with network code: {network_code}") - - client = ad_manager.AdManagerClient(oauth2_client, "AdCP-Sales-Agent-Setup", network_code=network_code) - - # Use GoogleAdManager adapter to fetch advertisers (eliminates code duplication) - from src.adapters.google_ad_manager import GoogleAdManager - from src.core.schemas import Principal - - # Create mock principal for adapter initialization (not used for get_advertisers) - mock_principal = Principal( - principal_id="system", - name="System", - platform_mappings={ - "google_ad_manager": { - "advertiser_id": "system_temp", - "advertiser_name": "System (temp)", - } - }, - ) - - # Build GAM config from OAuth credentials - gam_config = { - "oauth_credentials": { - "client_id": oauth_client_id, - "client_secret": oauth_client_secret, - "refresh_token": refresh_token, - } - } - - # Initialize adapter - adapter = GoogleAdManager( - config=gam_config, - principal=mock_principal, - network_code=network_code, - advertiser_id=None, - trafficker_id=None, - dry_run=False, - tenant_id=tenant_id, - ) - - # Fetch ALL advertisers using shared implementation (with pagination) - companies = adapter.get_advertisers(fetch_all=True) - result["companies"] = companies - - # Get current user info - user_service = client.GetService("UserService") - current_user = user_service.getCurrentUser() - result["current_user"] = { - "id": current_user.id, - "name": current_user.name, - "email": current_user.email, - } - - except Exception as e: - # It's okay if we can't fetch companies/users - result["warning"] = f"Connected but couldn't fetch all resources: {str(e)}" - - return jsonify(result) - - except Exception as e: - return jsonify({"error": str(e)}), 500 diff --git a/src/admin/blueprints/inventory.py b/src/admin/blueprints/inventory.py index 26d8b56695..f4a1916673 100644 --- a/src/admin/blueprints/inventory.py +++ b/src/admin/blueprints/inventory.py @@ -1,6 +1,5 @@ """Inventory and orders management blueprint.""" -import json import logging import time @@ -877,7 +876,6 @@ def analyze_ad_server_inventory(tenant_id): tenant = db_session.scalars(select(Tenant).filter_by(tenant_id=tenant_id)).first() adapter_type = None - adapter_config = {} # Check database for adapter configuration if tenant and tenant.ad_server: @@ -915,21 +913,6 @@ def analyze_ad_server_inventory(tenant_id): if not principal_obj: return jsonify({"error": "No principal found for tenant"}), 404 - # Create principal object - from src.core.schemas import Principal as PrincipalSchema - - # Handle both string (SQLite) and dict (PostgreSQL JSONB) formats - mappings = principal_obj.platform_mappings - if mappings and isinstance(mappings, str): - mappings = json.loads(mappings) - elif not mappings: - mappings = {} - principal = PrincipalSchema( - principal_id=principal_obj.principal_id, - name=principal_obj.name, - platform_mappings=mappings, - ) - # TODO: Get adapter instance and call actual discovery methods # For now, return mock analysis data # from src.adapters import get_adapter diff --git a/src/admin/blueprints/oidc.py b/src/admin/blueprints/oidc.py index 2f49984e66..fb9f8c361e 100644 --- a/src/admin/blueprints/oidc.py +++ b/src/admin/blueprints/oidc.py @@ -99,7 +99,7 @@ def save_config(tenant_id: str): return jsonify({"error": "client_secret is required for new configuration"}), 400 try: - config = save_oidc_config( + save_oidc_config( tenant_id=tenant_id, provider=provider, client_id=client_id, diff --git a/src/admin/blueprints/products.py b/src/admin/blueprints/products.py index e5513bd680..204151f47a 100644 --- a/src/admin/blueprints/products.py +++ b/src/admin/blueprints/products.py @@ -1708,7 +1708,9 @@ def edit_product(tenant_id, product_id): # Otherwise, preserve existing config structure if line_item_type: gam_config_service = GAMProductConfigService() - default_config = gam_config_service.generate_default_config(product.delivery_type, formats) + default_config = gam_config_service.generate_default_config( + product.delivery_type, product.format_ids + ) # Merge default config into base_config (preserving other fields) base_config.update(default_config) @@ -2524,8 +2526,6 @@ def unassign_inventory_from_product(tenant_id, product_id, mapping_id): # Store details for logging inventory_name = f"{mapping.inventory_type}:{mapping.inventory_id}" - inventory_id_to_remove = mapping.inventory_id - inventory_type_to_remove = mapping.inventory_type # Delete the mapping db_session.delete(mapping) diff --git a/src/admin/blueprints/workflows.py b/src/admin/blueprints/workflows.py index a7b49886ed..3f49b54451 100644 --- a/src/admin/blueprints/workflows.py +++ b/src/admin/blueprints/workflows.py @@ -378,9 +378,6 @@ def reject_workflow_step(tenant_id, workflow_id, step_id): # Get and update the workflow step via repository (tenant-scoped) workflow_repo = WorkflowRepository(db, tenant_id) - user_info = session.get("user", {}) - user_email = user_info.get("email", "system") if isinstance(user_info, dict) else str(user_info) - step = workflow_repo.update_status( step_id, status="rejected", diff --git a/src/admin/services/business_activity_service.py b/src/admin/services/business_activity_service.py index 2e09633d4a..19fe2c19f2 100644 --- a/src/admin/services/business_activity_service.py +++ b/src/admin/services/business_activity_service.py @@ -82,16 +82,12 @@ def get_business_activities(tenant_id: str, limit: int = 50) -> list[dict]: operation = log.operation or "unknown" if operation.startswith("A2A."): activity_type = "a2a" - icon = "📡" elif operation.startswith("AdCP."): activity_type = "adcp" - icon = "🔌" elif operation.startswith("MCP."): activity_type = "mcp" - icon = "🔗" else: activity_type = "system" - icon = "⚙️" # Build title from operation operation_clean = operation.replace("AdCP.", "").replace("A2A.", "").replace("MCP.", "") @@ -176,10 +172,8 @@ def get_business_activities(tenant_id: str, limit: int = 50) -> list[dict]: # Build description if log.success: status_text = "✓ Success" - badge_type = "success" else: status_text = f"✗ Failed: {log.error_message or 'Unknown error'}" - badge_type = "error" # Extract key details for description description_parts = [status_text] diff --git a/src/admin/services/dashboard_service.py b/src/admin/services/dashboard_service.py index 3ed4ffd3eb..2b99539eb5 100644 --- a/src/admin/services/dashboard_service.py +++ b/src/admin/services/dashboard_service.py @@ -794,7 +794,7 @@ def health_check() -> dict[str, Any]: db_session.execute(text("SELECT 1")).scalar() # Test audit logs table (our single data source) - test_activities = get_business_activities("health_check", limit=1) + get_business_activities("health_check", limit=1) return { "status": "healthy", diff --git a/src/admin/tests/unit/test_auth.py b/src/admin/tests/unit/test_auth.py index 9d1a45d857..d3a10d0230 100644 --- a/src/admin/tests/unit/test_auth.py +++ b/src/admin/tests/unit/test_auth.py @@ -159,23 +159,11 @@ def test_is_tenant_admin_database(self, mock_get_db_session, mock_is_super_admin assert not is_tenant_admin("user@tenant.com", "tenant_123") # Test 3: User is inactive - mock_user_inactive = Mock() - mock_user_inactive.is_admin = True - mock_user_inactive.is_active = False - - mock_user_query_inactive = MagicMock() - # When is_active=False, the filter_by chain should return no results (None) - mock_user_query_inactive.filter_by.return_value.filter_by.return_value.first.return_value = None - - def query_side_effect_inactive(model): - if hasattr(model, "__name__"): - if model.__name__ == "TenantManagementConfig": - return mock_superadmin_query - elif model.__name__ == "User": - return mock_user_query_inactive - return mock_user_query_inactive - - mock_session.query.side_effect = query_side_effect_inactive + # When is_active=False, the filter_by chain returns no results (None) + mock_scalars_inactive = Mock() + mock_scalars_inactive.first.return_value = None + mock_session.scalars.return_value = mock_scalars_inactive + assert not is_tenant_admin("admin@tenant.com", "tenant_123") diff --git a/src/core/context_manager.py b/src/core/context_manager.py index 77374d9327..e1963e3534 100644 --- a/src/core/context_manager.py +++ b/src/core/context_manager.py @@ -636,7 +636,6 @@ def _send_push_notifications(self, step: WorkflowStep, new_status: str, session: return tenant_id = context.tenant_id - principal_id = context.principal_id # Workflow callbacks are request-scoped. Durable catalog-change # subscriptions live in push_notification_configs with a separate diff --git a/src/core/format_resolver.py b/src/core/format_resolver.py index fb401776a5..e3bddea6f4 100644 --- a/src/core/format_resolver.py +++ b/src/core/format_resolver.py @@ -241,10 +241,6 @@ def _get_product_format_override( return None # Get base format from creative agent registry (WITHOUT product_id to avoid recursion) - from src.core.creative_agent_registry import get_creative_agent_registry - - registry = get_creative_agent_registry() - try: # format_id is a string key in format_overrides dict # Pass agent_url to find the base format from the correct creative agent diff --git a/src/core/mcp_context_wrapper.py b/src/core/mcp_context_wrapper.py index a3dfbc06b8..f3aa8ba51b 100644 --- a/src/core/mcp_context_wrapper.py +++ b/src/core/mcp_context_wrapper.py @@ -276,11 +276,9 @@ def _replace_context_in_args(self, args: tuple, kwargs: dict, tool_context: Tool """Replace FastMCP Context with ToolContext in arguments.""" # Replace in kwargs: set on whichever key carried the FastMCP context (supports 'ctx' or others) new_kwargs = {} - replaced = False for k, v in kwargs.items(): if isinstance(v, FastMCPContext): new_kwargs[k] = tool_context - replaced = True else: new_kwargs[k] = v kwargs = new_kwargs diff --git a/src/core/tools/creative_formats.py b/src/core/tools/creative_formats.py index d25246f710..d4b668b10b 100644 --- a/src/core/tools/creative_formats.py +++ b/src/core/tools/creative_formats.py @@ -5,7 +5,6 @@ """ import logging -import time from adcp.types import Format as AdcpFormat from adcp.utils.format_assets import get_format_assets @@ -48,8 +47,6 @@ def _list_creative_formats_impl( Uses CreativeAgentRegistry for dynamic format discovery with caching. Supports optional filtering by type, standard_only, category, and format_ids. """ - start_time = time.time() - # Use default request if none provided # All ListCreativeFormatsRequest fields have defaults (None) per AdCP spec if req is None: diff --git a/src/core/tools/creatives/listing.py b/src/core/tools/creatives/listing.py index df143a440f..312537c6b6 100644 --- a/src/core/tools/creatives/listing.py +++ b/src/core/tools/creatives/listing.py @@ -241,22 +241,6 @@ def _list_creatives_impl( # Convert to schema objects for db_creative in db_creatives: - # Handle content_uri - required field even for snippet creatives - # For snippet creatives, provide an HTML-looking URL to pass validation - snippet = db_creative.data.get("snippet") if db_creative.data else None - if snippet: - content_uri = ( - db_creative.data.get("url") or "" - if db_creative.data - else "" - ) - else: - content_uri = ( - db_creative.data.get("url") or "https://placeholder.example.com/missing.jpg" - if db_creative.data - else "https://placeholder.example.com/missing.jpg" - ) - # Build Creative directly with explicit types to satisfy mypy from src.core.schemas import FormatId, url @@ -385,9 +369,6 @@ def _list_creatives_impl( if total_count > len(creatives): message += f" (page {page} of {total_pages} total)" - # Calculate offset for pagination - offset_calc = (page - 1) * limit - # Import required schema classes from src.core.schemas import Pagination as SchemaPagination from src.core.schemas import QuerySummary diff --git a/src/core/tools/media_buy_create.py b/src/core/tools/media_buy_create.py index ec758460ae..986f9dc8cd 100644 --- a/src/core/tools/media_buy_create.py +++ b/src/core/tools/media_buy_create.py @@ -1054,11 +1054,8 @@ def _execute_adapter_media_buy_creation( else: logger.info(f"[ADAPTER] create_media_buy submitted async task: {response.task_id}") return response - except Exception as adapter_error: - import traceback - - error_traceback = traceback.format_exc() - logger.error(f"[ADAPTER] create_media_buy failed:\n{error_traceback}") + except Exception: + logger.exception("[ADAPTER] create_media_buy failed") raise @@ -4143,12 +4140,9 @@ async def _create_media_buy_impl( # Call adapter using shared creation logic # Note: start_time variable already resolved from 'asap' to actual datetime if needed # This uses the same function as manual approval to ensure consistency across adapters - try: - response = _execute_adapter_media_buy_creation( - req, packages, start_time, end_time, package_pricing_info, principal, testing_ctx, tenant=tenant - ) - except Exception as adapter_error: - raise + response = _execute_adapter_media_buy_creation( + req, packages, start_time, end_time, package_pricing_info, principal, testing_ctx, tenant=tenant + ) # Check if adapter returned an error response FIRST (before accessing any fields) # With oneOf pattern, response can be CreateMediaBuySuccess or CreateMediaBuyError diff --git a/src/core/tools/products.py b/src/core/tools/products.py index 0915134c15..8e7c297fe1 100644 --- a/src/core/tools/products.py +++ b/src/core/tools/products.py @@ -5,7 +5,6 @@ """ import logging -import os import time from typing import Any @@ -24,7 +23,6 @@ from src.core.schemas import ( GetProductsResponse, # Extends library Product ) -from src.core.testing_hooks import AdCPTestContext from src.core.tracing import traced from src.core.validation_helpers import safe_parse_json_field from src.services.policy_check_service import PolicyCheckService, PolicyStatus @@ -229,7 +227,6 @@ async def _get_products_impl( if identity is None: raise AdCPValidationError("Identity is required") - testing_ctx: AdCPTestContext | None = identity.testing_context or AdCPTestContext() principal_id: str | None = identity.principal_id tenant: dict[str, Any] = identity.tenant if identity.tenant else {} @@ -279,10 +276,6 @@ async def _get_products_impl( if not offering: offering = "Generic product inquiry" - # Skip strict validation in test environments (allow simple test values) - - is_test_mode = (testing_ctx and testing_ctx.test_session_id is not None) or os.getenv("ADCP_TESTING") == "true" - # Note: brand_manifest validation is handled by Pydantic schema, no need for runtime validation here # Check policy compliance first (if enabled) @@ -292,7 +285,6 @@ async def _get_products_impl( # Only run policy checks if enabled in tenant settings policy_check_enabled = advertising_policy.get("enabled", False) # Default to False for new tenants - policy_disabled_reason = None # Extract brief text early - needed for policy checks, dynamic variants, and AI ranking brief_text = req.brief if req.brief else "" @@ -300,7 +292,6 @@ async def _get_products_impl( if not policy_check_enabled: # Skip policy checks if disabled policy_result = None - policy_disabled_reason = "disabled_by_tenant" logger.info(f"Policy checks disabled for tenant {tenant['tenant_id']}") else: # Get tenant's Gemini API key for policy checks @@ -308,7 +299,6 @@ async def _get_products_impl( if not tenant_gemini_key: # No API key - cannot run policy checks policy_result = None - policy_disabled_reason = "no_gemini_api_key" logger.warning(f"Policy checks enabled but no Gemini API key configured for tenant {tenant['tenant_id']}") else: policy_service = PolicyCheckService(gemini_api_key=tenant_gemini_key) @@ -358,9 +348,8 @@ async def _get_products_impl( }, ) - # Fail open by default (allow campaigns) with warning in response + # Fail open by default (allow campaigns) policy_result = None - policy_disabled_reason = f"service_error: {type(e).__name__}" logger.warning(f"Policy check failed, allowing campaign by default: {e}") # Handle policy result based on settings diff --git a/src/core/tools/signals.py b/src/core/tools/signals.py index 8e0b63cf80..ac7bab2475 100644 --- a/src/core/tools/signals.py +++ b/src/core/tools/signals.py @@ -8,7 +8,6 @@ import json import logging import re -import time import uuid from typing import Any @@ -36,7 +35,6 @@ from adcp.types.generated_poc.signals.get_signals_response import Range from pydantic import ValidationError -from src.core.auth import get_principal_object from src.core.database.models import TenantSignal from src.core.resolved_identity import ResolvedIdentity from src.core.schemas import ( @@ -47,7 +45,6 @@ SignalDeployment, ) from src.core.signal_ids import adcp_safe_signal_id -from src.core.testing_hooks import AdCPTestContext def _cpm_pricing_option(cpm: float, currency: str = "USD") -> list[VendorPricingOption]: @@ -559,8 +556,6 @@ async def _activate_signal_impl( Returns: ActivateSignalResponse with activation status """ - start_time = time.time() - # Authentication required for signal activation principal_id = identity.principal_id if identity else None @@ -568,17 +563,8 @@ async def _activate_signal_impl( if not identity or not identity.tenant: raise AdCPAuthenticationError("No tenant context available") - # Get the Principal object with ad server mappings if not principal_id: raise AdCPAuthenticationError("Authentication required for signal activation") - principal = get_principal_object(principal_id, tenant_id=identity.tenant_id) - - # Apply testing hooks - if not identity: - raise AdCPValidationError("Context required for signal activation", recovery="terminal") - testing_ctx = identity.testing_context if identity else AdCPTestContext() - campaign_info = {"endpoint": "activate_signal", "signal_id": signal_agent_segment_id} - # Note: apply_testing_hooks modifies response data dict, not called here as no response yet try: from src.core.database.repositories.uow import TenantSignalUoW diff --git a/src/services/delivery_simulator.py b/src/services/delivery_simulator.py index 1fe44c3c07..a275016616 100644 --- a/src/services/delivery_simulator.py +++ b/src/services/delivery_simulator.py @@ -311,10 +311,6 @@ def _run_simulation( # Calculate impressions (assume $10 CPM) impressions = int(spend / 0.01) - # Calculate elapsed hours for webhook - elapsed_hours = elapsed_simulated_seconds / 3600 - total_hours = campaign_duration / 3600 - # Determine status if progress_ratio >= 1.0: status = "completed" diff --git a/src/services/delivery_webhook_scheduler.py b/src/services/delivery_webhook_scheduler.py index 541762257f..23fc24ccb1 100644 --- a/src/services/delivery_webhook_scheduler.py +++ b/src/services/delivery_webhook_scheduler.py @@ -333,6 +333,7 @@ async def _send_report_for_media_buy( for d in (delivery_response.media_buy_deliveries or []) ) delivery_response.partial_data = partial + delivery_response.sequence_number = sequence_number delivery_response.unavailable_count = 0 # TODO: Count reporting_delayed/failed deliveries # Extract webhook URL and authentication diff --git a/src/services/format_metrics_service.py b/src/services/format_metrics_service.py index 4b1e4c3df0..3618717465 100644 --- a/src/services/format_metrics_service.py +++ b/src/services/format_metrics_service.py @@ -257,7 +257,6 @@ def aggregate_all_tenants(period_days: int = 30) -> dict[str, Any]: Returns: Summary of aggregation across all tenants """ - from src.adapters.gam.auth import GAMAuthManager from src.adapters.gam.client import GAMClientManager from src.core.database.models import AdapterConfig @@ -294,7 +293,6 @@ def aggregate_all_tenants(period_days: int = 30) -> dict[str, Any]: adapter_repo = AdapterConfigRepository(db_session, tenant_id) gam_config = adapter_repo.get_gam_config(adapter_config) - auth_manager = GAMAuthManager(gam_config) client_manager = GAMClientManager(gam_config, adapter_config.gam_network_code) gam_client = client_manager.get_client() From 205543f53d4c0e97b193872cb6af6a0a321ea3b6 Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 16 Jul 2026 18:44:55 +0600 Subject: [PATCH 03/17] chore: enforce E722/F821/F841/E741 by removing stale ruff ignores The blanket ignores documented counts that no longer exist (the "12 bare excepts" and "4 F821 false positives" were already fixed; the remaining F841/E741 sites were removed in the previous commit). Fix the last six violations in scripts/ and promote all four rules to enforced so regressions fail the build. tests/ keeps a scoped per-file exemption for F841/E741 (154 pre-existing arrange-only bindings) to avoid churning guarded test files. Co-Authored-By: Claude Fable 5 --- .claude/scripts/inspect_bdd_steps.py | 2 +- pyproject.toml | 11 ++++---- scripts/graduate_pending.py | 3 -- scripts/reclassify_obligations.py | 42 ++++++++++++++-------------- 4 files changed, 28 insertions(+), 30 deletions(-) diff --git a/.claude/scripts/inspect_bdd_steps.py b/.claude/scripts/inspect_bdd_steps.py index af9677f6fd..80d45d28c4 100644 --- a/.claude/scripts/inspect_bdd_steps.py +++ b/.claude/scripts/inspect_bdd_steps.py @@ -273,7 +273,7 @@ def _collect_context_for_step(step: BddStepInfo) -> str: full_source = Path(step.file_path).read_text() # Only include imports and helper functions, not the full file lines = full_source.splitlines() - imports = [l for l in lines if l.startswith(("import ", "from "))] + imports = [line for line in lines if line.startswith(("import ", "from "))] if imports: context_parts.append("## Imports in step file\n" + "\n".join(imports)) except OSError: diff --git a/pyproject.toml b/pyproject.toml index b6e796604e..093d9b1311 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -170,13 +170,12 @@ select = [ ignore = [ # Justified suppressions — each must explain WHY it's ignored, not just WHAT it is. # Do not add new entries without a justification comment. + # E722/F821/E741/F841 were fixed to zero in src/ and scripts/ and removed + # from this list — they are now enforced (tests/ keeps a scoped exemption + # in per-file-ignores below). "E501", # Handled by ruff formatter; manual enforcement would conflict "E402", # Flask app (admin_ui.py) requires imports after app config; 3 occurrences - "E722", # 12 bare excepts in error-recovery paths (adapters, auth); TODO: add specific exceptions - "F821", # 4 false positives from conditional imports and TYPE_CHECKING blocks - "E741", # 3 uses of `l` variable in loops; renaming would hurt readability in math-heavy code "B027", # 6 empty methods in BaseAdapter — intentional optional interface, not abstract - "F841", # 5 remaining unused vars in complex destructuring; each reviewed, kept for clarity "C901", # 216 complex functions — structural; fix incrementally per-module "PLR2004", # 158 magic values — too noisy for signal; numeric constants in config/tests are readable "PLR0911", # 68 too-many-return-statements — structural; fix incrementally @@ -189,7 +188,9 @@ ignore = [ "inspect.getsource".msg = "inspect.getsource() is banned in tests. Write behavioral tests that exercise the actual code path instead of asserting strings exist in source code. See salesagent-gqzp." [tool.ruff.lint.per-file-ignores] -"tests/*" = ["E722", "F821"] +# F841/E741 in tests: 154 unused vars (fixture side effects, arrange-only +# bindings) predate enforcement; shrink over time, never grow. +"tests/*" = ["E722", "F821", "F841", "E741"] "admin_ui.py" = ["E402", "E722"] # ─── Coverage configuration ────────────────────────────────────── diff --git a/scripts/graduate_pending.py b/scripts/graduate_pending.py index bcac5878e9..592d428baa 100644 --- a/scripts/graduate_pending.py +++ b/scripts/graduate_pending.py @@ -81,13 +81,10 @@ def analyze(report_path: str) -> dict: # Categorize graduate_all_transports = [] # (scenario, row) where all 4 xpass graduate_partial = [] # (scenario, row, transports) where some xpass - xfailed_all = [] # all 4 xfail — no action needed for (scenario, row), transport_outcomes in sorted(results.items()): xpass_transports = {t for t, o in transport_outcomes.items() if o == "xpassed"} - xfail_transports = {t for t, o in transport_outcomes.items() if o == "xfailed"} pass_transports = {t for t, o in transport_outcomes.items() if o == "passed"} - fail_transports = {t for t, o in transport_outcomes.items() if o == "failed"} if not xpass_transports: continue diff --git a/scripts/reclassify_obligations.py b/scripts/reclassify_obligations.py index c6a93529ec..fad8b37a7c 100644 --- a/scripts/reclassify_obligations.py +++ b/scripts/reclassify_obligations.py @@ -234,14 +234,14 @@ def _extract_obligations_from_uc_doc(filepath: Path) -> list[Obligation]: text_lines = [scenario_title] j = i + 1 while j < len(lines): - l = lines[j] - if l.startswith("#### Scenario:") and j != i: + line = lines[j] + if line.startswith("#### Scenario:") and j != i: break - if l.startswith("### ") and not l.startswith("#### "): + if line.startswith("### ") and not line.startswith("#### "): break - if l.strip() == "---": + if line.strip() == "---": break - text_lines.append(l) + text_lines.append(line) j += 1 obligations.append( @@ -281,18 +281,18 @@ def _extract_obligations_from_rules(filepath: Path) -> list[Obligation]: layer = "behavioral" j = i + 1 while j < len(lines): - l = lines[j] - if l.startswith("### ") and not l.startswith("#### "): + line = lines[j] + if line.startswith("### ") and not line.startswith("#### "): break - if l.strip() == "---": + if line.strip() == "---": break - section_lines.append(l) - if "**Obligation ID**" in l: - m2 = re.search(r"\*\*Obligation ID\*\*\s+(\S+)", l) + section_lines.append(line) + if "**Obligation ID**" in line: + m2 = re.search(r"\*\*Obligation ID\*\*\s+(\S+)", line) if m2: oid = m2.group(1) - if "**Layer**" in l: - m3 = re.search(r"\*\*Layer\*\*\s+(\S+)", l) + if "**Layer**" in line: + m3 = re.search(r"\*\*Layer\*\*\s+(\S+)", line) if m3: layer = m3.group(1) j += 1 @@ -338,18 +338,18 @@ def _extract_obligations_from_constraints(filepath: Path) -> list[Obligation]: layer = "behavioral" j = i + 1 while j < len(lines): - l = lines[j] - if l.startswith("### ") and not l.startswith("#### "): + line = lines[j] + if line.startswith("### ") and not line.startswith("#### "): break - if l.strip() == "---": + if line.strip() == "---": break - section_lines.append(l) - if "**Obligation ID**" in l: - m = re.search(r"\*\*Obligation ID\*\*\s+(\S+)", l) + section_lines.append(line) + if "**Obligation ID**" in line: + m = re.search(r"\*\*Obligation ID\*\*\s+(\S+)", line) if m: oid = m.group(1) - if "**Layer**" in l: - m2 = re.search(r"\*\*Layer\*\*\s+(\S+)", l) + if "**Layer**" in line: + m2 = re.search(r"\*\*Layer\*\*\s+(\S+)", line) if m2: layer = m2.group(1) j += 1 From c36f59a68e538cfc45a39af310cf648d7a1dfdb3 Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 16 Jul 2026 18:48:57 +0600 Subject: [PATCH 04/17] refactor: apply safe readability fixes across src/ and scripts/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ruff --fix for RET (else-after-return family), SIM (needless bool, same-arm ifs, yoda conditions), PIE790 (unnecessary placeholders), PERF and C4 comprehension rules: 298 sites, safe fixes only — no unsafe fixes applied, no behavior changes. Enforce RET501/505/506/ 507/508 and PIE790 in the lint gate now that src/ and scripts/ are at zero (tests keep pre-existing occurrences via per-file-ignores). Also convert two stray prints to logging: a [NAMING DEBUG] print in the mock adapter's media-buy path and an exception print in default_products; deliberate console banners (config validation, database provisioning, health report CLI) are left as prints. Co-Authored-By: Claude Fable 5 --- .pre-commit-hooks/check_import_usage.py | 2 +- .pre-commit-hooks/check_type_ignore_count.py | 14 +-- examples/client_mcp.py | 2 +- examples/upstream_product_catalog_server.py | 61 ++++++------ pyproject.toml | 10 +- scripts/add_covers_tags.py | 17 ++-- scripts/deploy/run_all_services.py | 3 +- scripts/gam_prerequisites_check.py | 7 +- scripts/setup-dev.py | 11 +-- src/adapters/base.py | 7 -- src/adapters/base_inventory.py | 4 - src/adapters/base_workflow.py | 15 ++- src/adapters/broadstreet/adapter.py | 9 +- src/adapters/broadstreet/managers/workflow.py | 9 +- src/adapters/creative_engine.py | 1 - src/adapters/gam/auth.py | 10 +- src/adapters/gam/managers/creatives.py | 64 ++++++------- src/adapters/gam/managers/orders.py | 89 ++++++++---------- src/adapters/gam/managers/sync.py | 17 ++-- src/adapters/gam/managers/workflow.py | 7 +- src/adapters/gam/utils/error_handler.py | 19 ++-- src/adapters/gam/utils/formatters.py | 9 +- src/adapters/gam/utils/health_check.py | 47 +++++----- src/adapters/gam/utils/logging.py | 2 +- src/adapters/gam/utils/validation.py | 29 +++--- src/adapters/gam_data_freshness.py | 13 ++- src/adapters/gam_inventory_discovery.py | 3 +- src/adapters/gam_reporting_service.py | 2 +- src/adapters/google_ad_manager.py | 70 +++++++------- src/adapters/mock_ad_server.py | 51 +++++----- src/adapters/utils/timeout.py | 2 - src/admin/app.py | 14 +-- src/admin/blueprints/adapters.py | 6 +- src/admin/blueprints/api.py | 2 +- src/admin/blueprints/auth.py | 26 +++--- src/admin/blueprints/authorized_properties.py | 11 +-- src/admin/blueprints/creative_agents.py | 19 ++-- src/admin/blueprints/creatives.py | 92 +++++++++--------- src/admin/blueprints/gam.py | 4 +- src/admin/blueprints/inventory.py | 68 +++++++------- src/admin/blueprints/oidc.py | 3 +- src/admin/blueprints/products.py | 84 ++++++++--------- src/admin/blueprints/settings.py | 30 +++--- src/admin/blueprints/signals_agents.py | 19 ++-- src/admin/blueprints/tenants.py | 11 +-- src/admin/services/dashboard_service.py | 7 +- src/admin/sync_api.py | 2 +- src/admin/utils/helpers.py | 21 ++--- src/core/async_patterns.py | 9 +- src/core/auth.py | 13 ++- src/core/auth_utils.py | 52 +++++------ src/core/context_manager.py | 4 +- src/core/creative_agent_registry.py | 7 +- src/core/database/db_config.py | 5 +- src/core/database/models.py | 6 +- src/core/helpers/adapter_helpers.py | 19 ++-- src/core/helpers/creative_helpers.py | 7 +- src/core/mcp_context_wrapper.py | 14 ++- src/core/mcp_server_enhanced.py | 3 +- src/core/product_conversion.py | 64 ++++++------- src/core/property_list_resolver.py | 3 +- src/core/schemas/_base.py | 26 +++--- src/core/schemas/creative.py | 10 +- src/core/schemas/delivery.py | 10 +- src/core/schemas/product.py | 4 +- src/core/signals_agent_registry.py | 7 +- src/core/strategy.py | 72 +++++++------- src/core/tenant_status.py | 13 ++- src/core/testing_api.py | 11 +-- src/core/testing_hooks.py | 8 +- src/core/tool_error_logging.py | 68 +++++++------- src/core/tools/creatives/_assignments.py | 10 +- src/core/tools/creatives/_validation.py | 2 +- src/core/tools/media_buy_update.py | 93 +++++++++---------- src/core/tools/products.py | 4 +- src/core/tracing.py | 29 +++--- src/core/utils/mcp_client.py | 4 - src/core/utils/naming.py | 7 +- src/landing/landing_page.py | 2 +- src/services/activity_feed.py | 7 +- src/services/ai/factory.py | 27 +++--- src/services/background_approval_service.py | 11 +-- src/services/default_products.py | 5 +- src/services/dynamic_products.py | 2 +- src/services/gam_inventory_service.py | 12 +-- src/services/gam_orders_service.py | 32 ++++--- src/services/gcp_service_account_service.py | 7 +- src/services/media_buy_status_scheduler.py | 5 +- src/services/policy_check_service.py | 9 +- src/services/property_discovery_service.py | 37 ++++---- src/services/property_verification_service.py | 9 +- src/services/webhook_verification.py | 2 - 92 files changed, 816 insertions(+), 960 deletions(-) diff --git a/.pre-commit-hooks/check_import_usage.py b/.pre-commit-hooks/check_import_usage.py index 0c2caba941..3738a43df6 100755 --- a/.pre-commit-hooks/check_import_usage.py +++ b/.pre-commit-hooks/check_import_usage.py @@ -94,7 +94,7 @@ def _get_name(self, node) -> str | None: """Extract name from node (handles Name and Attribute).""" if isinstance(node, ast.Name): return node.id - elif isinstance(node, ast.Attribute): + if isinstance(node, ast.Attribute): # For chained attributes like foo.bar.Baz, just get the first part # since that's what needs to be imported base = node diff --git a/.pre-commit-hooks/check_type_ignore_count.py b/.pre-commit-hooks/check_type_ignore_count.py index b33ab12354..bd77db5d32 100755 --- a/.pre-commit-hooks/check_type_ignore_count.py +++ b/.pre-commit-hooks/check_type_ignore_count.py @@ -94,16 +94,16 @@ def main() -> int: print(" Run: mypy src/your_file.py --config-file=mypy.ini", file=sys.stderr) return 1 - elif current_count == baseline_count: + if current_count == baseline_count: print(f"✓ Type ignore count unchanged: {current_count}") return 0 - else: # current_count < baseline_count - decrease = baseline_count - current_count - print(f"🎉 Type ignore count decreased from {baseline_count} to {current_count} (-{decrease})!") - print(f" Automatically updating {BASELINE_FILE}...") - write_baseline(baseline_file, current_count) - return 0 + # current_count < baseline_count + decrease = baseline_count - current_count + print(f"🎉 Type ignore count decreased from {baseline_count} to {current_count} (-{decrease})!") + print(f" Automatically updating {BASELINE_FILE}...") + write_baseline(baseline_file, current_count) + return 0 if __name__ == "__main__": diff --git a/examples/client_mcp.py b/examples/client_mcp.py index 3a1c6f74b6..5a4f8d9f57 100755 --- a/examples/client_mcp.py +++ b/examples/client_mcp.py @@ -115,7 +115,7 @@ async def interactive_mode(client: Client): if command == "quit": break - elif command == "get_products": + if command == "get_products": result = await client.call_tool("get_products", {}) if result and hasattr(result, "products"): for product in result.products[:5]: diff --git a/examples/upstream_product_catalog_server.py b/examples/upstream_product_catalog_server.py index 1ac3c6efee..dab919e992 100755 --- a/examples/upstream_product_catalog_server.py +++ b/examples/upstream_product_catalog_server.py @@ -204,40 +204,39 @@ async def match_products(self, brief: str, all_products: list[dict[str, Any]]) - # Filter products by AI selection return [p for p in all_products if p["product_id"] in selected_ids] - else: - # Fallback to rule-based matching - analysis = self.analyze_brief(brief) - scored_products = [] - - for product in all_products: - score = 0 - - # Topic matching - product_topics = product.get("targeting_template", {}).get("content_cat_any_of", []) - for topic in analysis["topics"]: - if topic in product_topics: - score += 10 - - # Format matching - product_formats = [f["type"] for f in product.get("formats", [])] - for format_type in analysis["formats"]: - if format_type in product_formats: - score += 5 - - # Special timing matching - if "march_madness" in analysis["timing"] and product.get("availability", {}).get("march_madness"): - score += 15 - - # Audience matching - if "premium" in analysis["audience"] and product.get("cpm", 0) > 20: + # Fallback to rule-based matching + analysis = self.analyze_brief(brief) + scored_products = [] + + for product in all_products: + score = 0 + + # Topic matching + product_topics = product.get("targeting_template", {}).get("content_cat_any_of", []) + for topic in analysis["topics"]: + if topic in product_topics: + score += 10 + + # Format matching + product_formats = [f["type"] for f in product.get("formats", [])] + for format_type in analysis["formats"]: + if format_type in product_formats: score += 5 - if score > 0: - scored_products.append((score, product)) + # Special timing matching + if "march_madness" in analysis["timing"] and product.get("availability", {}).get("march_madness"): + score += 15 - # Sort by score and return top products - scored_products.sort(key=lambda x: x[0], reverse=True) - return [p[1] for p in scored_products[:3]] + # Audience matching + if "premium" in analysis["audience"] and product.get("cpm", 0) > 20: + score += 5 + + if score > 0: + scored_products.append((score, product)) + + # Sort by score and return top products + scored_products.sort(key=lambda x: x[0], reverse=True) + return [p[1] for p in scored_products[:3]] # Global matcher instance diff --git a/pyproject.toml b/pyproject.toml index 093d9b1311..7ab9c6c7d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -166,6 +166,12 @@ select = [ "C90", # mccabe complexity "PLR", # pylint refactor "TID251", # banned-api — enforce prohibited function usage + "RET501", # unnecessary explicit `return None` + "RET505", # superfluous else after return + "RET506", # superfluous else after raise + "RET507", # superfluous else after continue + "RET508", # superfluous else after break + "PIE790", # unnecessary pass/ellipsis placeholder ] ignore = [ # Justified suppressions — each must explain WHY it's ignored, not just WHAT it is. @@ -190,7 +196,9 @@ ignore = [ [tool.ruff.lint.per-file-ignores] # F841/E741 in tests: 154 unused vars (fixture side effects, arrange-only # bindings) predate enforcement; shrink over time, never grow. -"tests/*" = ["E722", "F821", "F841", "E741"] +# RET5/PIE790: src/ and scripts/ were cleaned to zero and are enforced; +# tests keep pre-existing occurrences until cleaned separately. +"tests/*" = ["E722", "F821", "F841", "E741", "RET5", "PIE790"] "admin_ui.py" = ["E402", "E722"] # ─── Coverage configuration ────────────────────────────────────── diff --git a/scripts/add_covers_tags.py b/scripts/add_covers_tags.py index 47bd46bd64..c80595caef 100644 --- a/scripts/add_covers_tags.py +++ b/scripts/add_covers_tags.py @@ -532,16 +532,15 @@ def _find_func_line(locations: dict[tuple[str | None, str], int], key: str) -> i if "::" in key: class_name, func_name = key.split("::", 1) return locations.get((class_name, func_name)) - else: - # Search all classes for this function name - matches = [(k, v) for k, v in locations.items() if k[1] == key] - if len(matches) == 1: - return matches[0][1] - elif len(matches) > 1: - # Multiple matches — return None (caller should use ClassName:: prefix) - print(f" AMBIGUOUS: {key} found in {[m[0][0] for m in matches]}") - return None + # Search all classes for this function name + matches = [(k, v) for k, v in locations.items() if k[1] == key] + if len(matches) == 1: + return matches[0][1] + if len(matches) > 1: + # Multiple matches — return None (caller should use ClassName:: prefix) + print(f" AMBIGUOUS: {key} found in {[m[0][0] for m in matches]}") return None + return None def _find_docstring_info(lines: list[str], func_line: int) -> tuple[int, int, bool] | None: diff --git a/scripts/deploy/run_all_services.py b/scripts/deploy/run_all_services.py index b32d05a6cb..ffdffc652b 100644 --- a/scripts/deploy/run_all_services.py +++ b/scripts/deploy/run_all_services.py @@ -308,8 +308,7 @@ def run_nginx(): if test_proc.returncode != 0: print(f"❌ Nginx configuration test failed: {test_proc.stderr}") return - else: - print("✅ Nginx configuration test passed") + print("✅ Nginx configuration test passed") # Start nginx proc = subprocess.Popen( diff --git a/scripts/gam_prerequisites_check.py b/scripts/gam_prerequisites_check.py index c4c8edf63f..bbd7584a48 100644 --- a/scripts/gam_prerequisites_check.py +++ b/scripts/gam_prerequisites_check.py @@ -60,10 +60,9 @@ def main(): print("Alternative: Use Service Account authentication via Admin UI") print("(No OAuth setup required)\n") return 1 - else: - print("All GAM OAuth prerequisites configured!") - print("You can now use OAuth authentication with GAM.\n") - return 0 + print("All GAM OAuth prerequisites configured!") + print("You can now use OAuth authentication with GAM.\n") + return 0 if __name__ == "__main__": diff --git a/scripts/setup-dev.py b/scripts/setup-dev.py index ac9a117364..d7910c3651 100755 --- a/scripts/setup-dev.py +++ b/scripts/setup-dev.py @@ -430,12 +430,11 @@ def wait_for_migrations() -> StepResult: ok=True, message="Database migrations completed", ) - else: - return StepResult( - name="migrations", - ok=False, - message=f"db-init exited with code {iparts[1]}", - ) + return StepResult( + name="migrations", + ok=False, + message=f"db-init exited with code {iparts[1]}", + ) except (subprocess.CalledProcessError, FileNotFoundError): pass time.sleep(3) diff --git a/src/adapters/base.py b/src/adapters/base.py index eb2273f2ed..cb308a352c 100644 --- a/src/adapters/base.py +++ b/src/adapters/base.py @@ -827,14 +827,12 @@ def create_media_buy( Returns: CreateMediaBuyResponse with media buy details """ - pass @abstractmethod def add_creative_assets( self, media_buy_id: str, assets: list[dict[str, Any]], today: datetime ) -> list[AssetStatus]: """Adds creative assets to an existing media buy.""" - pass @abstractmethod def associate_creatives(self, line_item_ids: list[str], platform_creative_ids: list[str]) -> list[dict[str, Any]]: @@ -851,19 +849,16 @@ def associate_creatives(self, line_item_ids: list[str], platform_creative_ids: l List of association results with status for each combination Example: [{"line_item_id": "123", "creative_id": "456", "status": "success"}] """ - pass @abstractmethod def check_media_buy_status(self, media_buy_id: str, today: datetime) -> CheckMediaBuyStatusResponse: """Checks the status of a media buy on the ad server.""" - pass @abstractmethod def get_media_buy_delivery( self, media_buy_id: str, date_range: ReportingPeriod, today: datetime ) -> AdapterGetMediaBuyDeliveryResponse: """Gets delivery data for a media buy.""" - pass def get_packages_snapshot( self, package_refs: list[tuple[str, str, str | None]] @@ -905,7 +900,6 @@ def update_media_buy( today: datetime, ) -> UpdateMediaBuyResponse: """Updates a media buy with a specific action.""" - pass def get_config_ui_endpoint(self) -> str | None: """ @@ -926,7 +920,6 @@ def register_ui_routes(self, app): def gam_product_config(tenant_id, product_id): return render_template('gam_config.html', ...) """ - pass def validate_product_config(self, config: dict[str, Any]) -> tuple[bool, str | None]: """ diff --git a/src/adapters/base_inventory.py b/src/adapters/base_inventory.py index ec1a19ff7a..9a771a40d3 100644 --- a/src/adapters/base_inventory.py +++ b/src/adapters/base_inventory.py @@ -63,7 +63,6 @@ def discover_inventory(self, refresh: bool = False) -> list[Any]: Returns: List of inventory items (platform-specific type) """ - pass @abstractmethod def validate_inventory_ids(self, inventory_ids: list[str]) -> tuple[list[str], list[str]]: @@ -75,7 +74,6 @@ def validate_inventory_ids(self, inventory_ids: list[str]) -> tuple[list[str], l Returns: Tuple of (valid_ids, invalid_ids) """ - pass @abstractmethod def build_inventory_response(self) -> dict[str, Any]: @@ -84,7 +82,6 @@ def build_inventory_response(self) -> dict[str, Any]: Returns: Dictionary with inventory details in platform-agnostic format """ - pass @abstractmethod def suggest_products(self) -> list[dict[str, Any]]: @@ -96,7 +93,6 @@ def suggest_products(self) -> list[dict[str, Any]]: Returns: List of suggested product configurations """ - pass def clear_cache(self) -> None: """Clear the inventory cache.""" diff --git a/src/adapters/base_workflow.py b/src/adapters/base_workflow.py index 7930bd28d2..b8109d588c 100644 --- a/src/adapters/base_workflow.py +++ b/src/adapters/base_workflow.py @@ -255,24 +255,23 @@ def _get_notification_details(self, step_id: str, action_details: dict[str, Any] "description": "Manual mode activated - human intervention needed", "color": "#FF9500", # Orange } - elif "approval" in automation_mode.lower() or "activate" in action_type.lower(): + if "approval" in automation_mode.lower() or "activate" in action_type.lower(): return { "title": f"{self.platform_name} Approval Required", "description": "Approval needed for operation", "color": "#FFD700", # Gold } - elif "background" in automation_mode.lower() or "working" in action_details.get("status", ""): + if "background" in automation_mode.lower() or "working" in action_details.get("status", ""): return { "title": f"{self.platform_name} Background Task Started", "description": "Background processing in progress", "color": "#36A2EB", # Blue } - else: - return { - "title": "Workflow Step Requires Attention", - "description": f"Workflow step {step_id} needs human intervention", - "color": "#36A2EB", # Blue - } + return { + "title": "Workflow Step Requires Attention", + "description": f"Workflow step {step_id} needs human intervention", + "color": "#36A2EB", # Blue + } def build_packages_summary(self, packages: list[MediaPackage]) -> list[dict[str, Any]]: """Build a summary of packages for workflow action details. diff --git a/src/adapters/broadstreet/adapter.py b/src/adapters/broadstreet/adapter.py index 301f6cb71f..d732dfd424 100644 --- a/src/adapters/broadstreet/adapter.py +++ b/src/adapters/broadstreet/adapter.py @@ -161,13 +161,12 @@ def _extract_campaign_id(self, media_buy_id: str) -> str: if media_buy_id.startswith("bs_"): return media_buy_id[3:] # Remove "bs_" prefix - elif media_buy_id.startswith("mb_"): + if media_buy_id.startswith("mb_"): # Legacy format or dry-run generated ID return media_buy_id[3:] - else: - # Assume it's already a raw campaign ID - logger.warning(f"Unexpected media_buy_id format: {media_buy_id}, using as-is") - return media_buy_id + # Assume it's already a raw campaign ID + logger.warning(f"Unexpected media_buy_id format: {media_buy_id}, using as-is") + return media_buy_id def _persist_advertisement_ids(self, media_buy_id: str, advertisement_ids: list[str]) -> None: """Persist Broadstreet advertisement IDs to package_config in the database. diff --git a/src/adapters/broadstreet/managers/workflow.py b/src/adapters/broadstreet/managers/workflow.py index c6cd5d0a62..0616d51a6b 100644 --- a/src/adapters/broadstreet/managers/workflow.py +++ b/src/adapters/broadstreet/managers/workflow.py @@ -194,18 +194,17 @@ def _get_notification_details(self, step_id: str, action_details: dict[str, Any] "description": "Manual mode activated - human intervention needed to create campaign", "color": "#FF9500", # Orange } - elif action_type == "activate_broadstreet_campaign": + if action_type == "activate_broadstreet_campaign": return { "title": "Broadstreet Campaign Activation Approval Required", "description": "Campaign created successfully - approval needed for activation", "color": "#FFD700", # Gold } - elif action_type == "creative_approval": + if action_type == "creative_approval": return { "title": "Broadstreet Creative Approval Required", "description": "Creatives uploaded - approval needed before activation", "color": "#9B59B6", # Purple } - else: - # Fall back to base class behavior - return super()._get_notification_details(step_id, action_details) + # Fall back to base class behavior + return super()._get_notification_details(step_id, action_details) diff --git a/src/adapters/creative_engine.py b/src/adapters/creative_engine.py index e637c64a64..b24ad35ff8 100644 --- a/src/adapters/creative_engine.py +++ b/src/adapters/creative_engine.py @@ -9,4 +9,3 @@ class CreativeEngineAdapter(ABC): @abstractmethod def process_creatives(self, creatives: list[Creative]) -> list[CreativeApprovalStatus]: """Processes creative assets, returning their status.""" - pass diff --git a/src/adapters/gam/auth.py b/src/adapters/gam/auth.py index 05baa9ec60..cf18f4ffb2 100644 --- a/src/adapters/gam/auth.py +++ b/src/adapters/gam/auth.py @@ -50,10 +50,9 @@ def get_credentials(self): try: if self.refresh_token: return self._get_oauth_credentials() - elif self.service_account_json or self.key_file: + if self.service_account_json or self.key_file: return self._get_service_account_credentials() - else: - raise ValueError("No valid authentication method configured") + raise ValueError("No valid authentication method configured") except Exception as e: # Log the type and short message only — never the full repr, # which on JSONDecodeError carries the input doc and could @@ -132,7 +131,6 @@ def get_auth_method(self) -> str: """Get the current authentication method name.""" if self.is_oauth_configured(): return "oauth" - elif self.is_service_account_configured(): + if self.is_service_account_configured(): return "service_account" - else: - return "none" + return "none" diff --git a/src/adapters/gam/managers/creatives.py b/src/adapters/gam/managers/creatives.py index f0fcd39432..1987c3bf56 100644 --- a/src/adapters/gam/managers/creatives.py +++ b/src/adapters/gam/managers/creatives.py @@ -438,11 +438,10 @@ def _get_creative_type(self, asset: dict[str, Any]) -> str: if asset.get("snippet") and asset.get("snippet_type"): if asset["snippet_type"] in ["vast_xml", "vast_url"]: return "vast" - else: - return "third_party_tag" - elif asset.get("template_variables"): + return "third_party_tag" + if asset.get("template_variables"): return "native" - elif asset.get("media_url") or asset.get("media_data"): + if asset.get("media_url") or asset.get("media_data"): # Check if HTML5 based on file extension or format media_url = asset.get("media_url", "") format_str = asset.get("format", "") @@ -452,27 +451,24 @@ def _get_creative_type(self, asset: dict[str, Any]) -> str: or "rich_media" in format_str.lower() ): return "html5" - else: - return "hosted_asset" - else: - # Auto-detect from legacy patterns for backward compatibility - url = asset.get("url", "") - format_str = asset.get("format", "") + return "hosted_asset" + # Auto-detect from legacy patterns for backward compatibility + url = asset.get("url", "") + format_str = asset.get("format", "") - if self._is_html_snippet(url): - return "third_party_tag" - elif "native" in format_str: - return "native" - elif url and (".xml" in url.lower() or "vast" in url.lower()): - return "vast" - elif ( - url.lower().endswith((".html", ".htm", ".html5", ".zip")) - or "html5" in format_str.lower() - or "rich_media" in format_str.lower() - ): - return "html5" - else: - return "hosted_asset" # Default + if self._is_html_snippet(url): + return "third_party_tag" + if "native" in format_str: + return "native" + if url and (".xml" in url.lower() or "vast" in url.lower()): + return "vast" + if ( + url.lower().endswith((".html", ".htm", ".html5", ".zip")) + or "html5" in format_str.lower() + or "rich_media" in format_str.lower() + ): + return "html5" + return "hosted_asset" # Default def _validate_creative_for_gam(self, asset: dict[str, Any]) -> list[str]: """Validate creative asset against GAM requirements before API submission. @@ -608,15 +604,14 @@ def _create_gam_creative( """ if creative_type == "third_party_tag": return self._create_third_party_creative(asset) - elif creative_type == "native": + if creative_type == "native": return self._create_native_creative(asset) - elif creative_type == "html5": + if creative_type == "html5": return self._create_html5_creative(asset) - elif creative_type == "hosted_asset": + if creative_type == "hosted_asset": return self._create_hosted_asset_creative(asset) - else: - logger.warning(f"Unsupported creative type: {creative_type}") - return None + logger.warning(f"Unsupported creative type: {creative_type}") + return None def _create_third_party_creative(self, asset: dict[str, Any]) -> dict[str, Any]: """Create a third-party creative for GAM.""" @@ -837,11 +832,11 @@ def _get_content_type(self, asset: dict[str, Any]) -> str: path = parsed.path.lower() if path.endswith((".jpg", ".jpeg")): return "image/jpeg" - elif path.endswith(".png"): + if path.endswith(".png"): return "image/png" - elif path.endswith(".gif"): + if path.endswith(".gif"): return "image/gif" - elif path.endswith((".mp4", ".mov")): + if path.endswith((".mp4", ".mov")): return "video/mp4" # Default @@ -852,8 +847,7 @@ def _determine_asset_type(self, asset: dict[str, Any]) -> str: content_type = self._get_content_type(asset) if content_type.startswith("video/"): return "video" - else: - return "image" + return "image" def _get_native_template_id(self, asset: dict[str, Any]) -> str: """Get the GAM native template ID for the asset.""" diff --git a/src/adapters/gam/managers/orders.py b/src/adapters/gam/managers/orders.py index 687b22dbaa..465f0090f2 100644 --- a/src/adapters/gam/managers/orders.py +++ b/src/adapters/gam/managers/orders.py @@ -173,15 +173,13 @@ def create_order( logger.info(f" Flight Dates: {start_time.date()} to {end_time.date()}") # Return a mock order ID for dry run return f"dry_run_order_{int(datetime.now(UTC).timestamp())}" - else: - order_service = self.client_manager.get_service("OrderService") - created_orders = order_service.createOrders([order]) - if created_orders: - order_id = str(created_orders[0]["id"]) - logger.info(f"✓ Created GAM Order ID: {order_id}") - return order_id - else: - raise Exception("Failed to create order - no orders returned") + order_service = self.client_manager.get_service("OrderService") + created_orders = order_service.createOrders([order]) + if created_orders: + order_id = str(created_orders[0]["id"]) + logger.info(f"✓ Created GAM Order ID: {order_id}") + return order_id + raise Exception("Failed to create order - no orders returned") @timeout(seconds=30) # 30 seconds timeout for status check def get_order_status(self, order_id: str) -> str: @@ -208,8 +206,7 @@ def get_order_status(self, order_id: str) -> str: if result and "results" in result and result["results"]: order = result["results"][0] return order["status"] if "status" in order else "UNKNOWN" - else: - return "NOT_FOUND" + return "NOT_FOUND" except Exception as e: logger.error(f"Error getting order status for {order_id}: {e}") return "ERROR" @@ -247,9 +244,8 @@ def archive_order(self, order_id: str) -> bool: if num_changes > 0: logger.info(f"✓ Successfully archived GAM Order {order_id}") return True - else: - logger.warning(f"No changes made when archiving Order {order_id} (may already be archived)") - return True # Consider this successful + logger.warning(f"No changes made when archiving Order {order_id} (may already be archived)") + return True # Consider this successful except Exception as e: logger.error(f"Failed to archive GAM Order {order_id}: {str(e)}") @@ -304,9 +300,8 @@ def approve_order(self, order_id: str, max_retries: int = 40, poll_interval: int if num_changes > 0: logger.info(f"✓ Successfully approved GAM Order {order_id} ({num_changes} changes)") return True - else: - logger.warning(f"No changes made when approving Order {order_id} (may already be approved)") - return True # Consider this successful if already approved + logger.warning(f"No changes made when approving Order {order_id} (may already be approved)") + return True # Consider this successful if already approved except Exception as e: error_str = str(e) @@ -323,25 +318,23 @@ def approve_order(self, order_id: str, max_retries: int = 40, poll_interval: int ) time.sleep(poll_interval) continue # Retry - else: - logger.error( - f"[APPROVAL] Failed to approve Order {order_id} after {max_retries} attempts " - f"({max_retries * poll_interval}s total): " - f"GAM forecasting still not ready. Order remains in DRAFT status." - ) - return False - else: - # PERMISSION_DENIED means the service account can never approve — raise - # so callers can switch to external-approval status polling. - if "PERMISSION_DENIED" in error_str or "OrderActionError.PERMISSION_DENIED" in error_str: - raise GAMOrderApprovalPermissionDenied( - f"Service account cannot approve GAM order {order_id} " - f"(OrderActionError.PERMISSION_DENIED). " - f"The order will remain in DRAFT until approved manually in GAM." - ) from e - # Other errors - don't retry - logger.error(f"Failed to approve GAM Order {order_id}: {error_str}") + logger.error( + f"[APPROVAL] Failed to approve Order {order_id} after {max_retries} attempts " + f"({max_retries * poll_interval}s total): " + f"GAM forecasting still not ready. Order remains in DRAFT status." + ) return False + # PERMISSION_DENIED means the service account can never approve — raise + # so callers can switch to external-approval status polling. + if "PERMISSION_DENIED" in error_str or "OrderActionError.PERMISSION_DENIED" in error_str: + raise GAMOrderApprovalPermissionDenied( + f"Service account cannot approve GAM order {order_id} " + f"(OrderActionError.PERMISSION_DENIED). " + f"The order will remain in DRAFT until approved manually in GAM." + ) from e + # Other errors - don't retry + logger.error(f"Failed to approve GAM Order {order_id}: {error_str}") + return False # Should not reach here, but just in case return False @@ -692,10 +685,11 @@ def log(msg): placeholder_height = placeholder.get("size", {}).get("height") # 1x1 placeholders are special (templates, native) - always include - if placeholder_width == 1 and placeholder_height == 1: - filtered_placeholders.append(placeholder) - # Include if we have creatives of this size - elif (placeholder_width, placeholder_height) in creative_sizes: + if ( + placeholder_width == 1 + and placeholder_height == 1 + or (placeholder_width, placeholder_height) in creative_sizes + ): filtered_placeholders.append(placeholder) if filtered_placeholders: @@ -1199,9 +1193,8 @@ def update_line_item_budget( f"(goal units: {new_goal_units}, pricing: {pricing_model})" ) return True - else: - logger.error(f"Failed to update line item {line_item_id} - GAM API returned no results") - return False + logger.error(f"Failed to update line item {line_item_id} - GAM API returned no results") + return False except Exception as e: error_str = str(e) @@ -1216,10 +1209,9 @@ def update_line_item_budget( ) time.sleep(wait_time) continue # Retry - else: - # Non-retryable error or last attempt - logger.error(f"Error updating line item {line_item_id} budget: {e}") - return False + # Non-retryable error or last attempt + logger.error(f"Error updating line item {line_item_id} budget: {e}") + return False # All retries exhausted logger.error(f"Failed to update line item {line_item_id} budget after {max_retries} attempts") @@ -1423,9 +1415,8 @@ def _update_line_item_status(self, line_item_id: str, new_status: str) -> bool: if updated_line_items: logger.info(f"✓ Updated line item {line_item_id} status to {new_status}") return True - else: - logger.error(f"Failed to update line item {line_item_id} status - GAM API returned no results") - return False + logger.error(f"Failed to update line item {line_item_id} status - GAM API returned no results") + return False except Exception as e: logger.error(f"Error updating line item {line_item_id} status: {e}") diff --git a/src/adapters/gam/managers/sync.py b/src/adapters/gam/managers/sync.py index cf4696a62d..9b178a9f40 100644 --- a/src/adapters/gam/managers/sync.py +++ b/src/adapters/gam/managers/sync.py @@ -542,15 +542,14 @@ def _get_recent_sync(self, db_session: Session, sync_type: str) -> dict[str, Any "status": "running", "message": "Sync already in progress", } - else: - summary = json.loads(recent_sync.summary) if recent_sync.summary else {} - return { - "sync_id": recent_sync.sync_id, - "status": "completed", - "completed_at": recent_sync.completed_at.isoformat() if recent_sync.completed_at else None, - "summary": summary, - "message": "Recent sync exists", - } + summary = json.loads(recent_sync.summary) if recent_sync.summary else {} + return { + "sync_id": recent_sync.sync_id, + "status": "completed", + "completed_at": recent_sync.completed_at.isoformat() if recent_sync.completed_at else None, + "summary": summary, + "message": "Recent sync exists", + } def _create_sync_job(self, db_session: Session, sync_type: str, triggered_by: str) -> SyncJob: """Create a new sync job record. diff --git a/src/adapters/gam/managers/workflow.py b/src/adapters/gam/managers/workflow.py index 91a5c342e5..e6103f151a 100644 --- a/src/adapters/gam/managers/workflow.py +++ b/src/adapters/gam/managers/workflow.py @@ -449,12 +449,11 @@ def _get_notification_details(self, step_id: str, action_details: dict[str, Any] "description": "Manual mode activated - human intervention needed to create GAM order", "color": "#FF9500", # Orange } - elif action_type == "activate_gam_order": + if action_type == "activate_gam_order": return { "title": "GAM Order Activation Approval Required", "description": "Order created successfully - approval needed for activation", "color": "#FFD700", # Gold } - else: - # Fall back to base class behavior - return super()._get_notification_details(step_id, action_details) + # Fall back to base class behavior + return super()._get_notification_details(step_id, action_details) diff --git a/src/adapters/gam/utils/error_handler.py b/src/adapters/gam/utils/error_handler.py index 4717a7fd7d..a49d7fe6ee 100644 --- a/src/adapters/gam/utils/error_handler.py +++ b/src/adapters/gam/utils/error_handler.py @@ -164,30 +164,29 @@ def map_gam_exception(exception: Exception) -> GAMError: if "AuthError" in type(exception).__name__ or "authentication" in error_message.lower(): return GAMAuthenticationError(f"GAM authentication failed: {error_message}", error_details) - elif "PermissionError" in type(exception).__name__ or "permission" in error_message.lower(): + if "PermissionError" in type(exception).__name__ or "permission" in error_message.lower(): return GAMPermissionError(f"GAM permission denied: {error_message}", error_details) - elif "ValidationError" in type(exception).__name__ or "invalid" in error_message.lower(): + if "ValidationError" in type(exception).__name__ or "invalid" in error_message.lower(): return GAMValidationError(f"GAM validation failed: {error_message}", error_details) - elif "QuotaError" in type(exception).__name__ or "quota" in error_message.lower(): + if "QuotaError" in type(exception).__name__ or "quota" in error_message.lower(): return GAMQuotaError(f"GAM quota exceeded: {error_message}", error_details) - elif "NetworkError" in type(exception).__name__ or "network" in error_message.lower(): + if "NetworkError" in type(exception).__name__ or "network" in error_message.lower(): return GAMNetworkError(f"GAM network error: {error_message}", error_details) - elif "TimeoutError" in type(exception).__name__ or "timeout" in error_message.lower(): + if "TimeoutError" in type(exception).__name__ or "timeout" in error_message.lower(): return GAMTimeoutError(f"GAM operation timed out: {error_message}", error_details) - elif "NotFoundError" in type(exception).__name__ or "not found" in error_message.lower(): + if "NotFoundError" in type(exception).__name__ or "not found" in error_message.lower(): return GAMResourceNotFoundError(f"GAM resource not found: {error_message}", error_details) - elif "DuplicateError" in type(exception).__name__ or "already exists" in error_message.lower(): + if "DuplicateError" in type(exception).__name__ or "already exists" in error_message.lower(): return GAMDuplicateResourceError(f"GAM resource already exists: {error_message}", error_details) - else: - # Default to unknown error - return GAMError(f"GAM error: {error_message}", GAMErrorType.UNKNOWN, error_details) + # Default to unknown error + return GAMError(f"GAM error: {error_message}", GAMErrorType.UNKNOWN, error_details) def with_retry( diff --git a/src/adapters/gam/utils/formatters.py b/src/adapters/gam/utils/formatters.py index fecb1656bd..0e6c1513f0 100644 --- a/src/adapters/gam/utils/formatters.py +++ b/src/adapters/gam/utils/formatters.py @@ -337,11 +337,10 @@ def format_duration(seconds: float) -> str: """ if seconds < 1: return f"{seconds * 1000:.0f}ms" - elif seconds < 60: + if seconds < 60: return f"{seconds:.1f}s" - elif seconds < 3600: + if seconds < 3600: minutes = seconds / 60 return f"{minutes:.1f}m" - else: - hours = seconds / 3600 - return f"{hours:.1f}h" + hours = seconds / 3600 + return f"{hours:.1f}h" diff --git a/src/adapters/gam/utils/health_check.py b/src/adapters/gam/utils/health_check.py index bbbbebabf3..87c8928ff1 100644 --- a/src/adapters/gam/utils/health_check.py +++ b/src/adapters/gam/utils/health_check.py @@ -201,14 +201,13 @@ def check_permissions(self, advertiser_id: str) -> HealthCheckResult: }, duration_ms=(time.time() - start_time) * 1000, ) - else: - return HealthCheckResult( - status=HealthStatus.UNHEALTHY, - check_name="permissions", - message="Missing required permissions", - details={"missing": missing_permissions}, - duration_ms=(time.time() - start_time) * 1000, - ) + return HealthCheckResult( + status=HealthStatus.UNHEALTHY, + check_name="permissions", + message="Missing required permissions", + details={"missing": missing_permissions}, + duration_ms=(time.time() - start_time) * 1000, + ) except Exception as e: return HealthCheckResult( @@ -321,7 +320,7 @@ def check_inventory_access(self, ad_unit_ids: list[str]) -> HealthCheckResult: }, duration_ms=(time.time() - start_time) * 1000, ) - elif accessible_units: + if accessible_units: return HealthCheckResult( status=HealthStatus.DEGRADED, check_name="inventory_access", @@ -329,14 +328,13 @@ def check_inventory_access(self, ad_unit_ids: list[str]) -> HealthCheckResult: details={"accessible": accessible_units, "inaccessible": inaccessible_units}, duration_ms=(time.time() - start_time) * 1000, ) - else: - return HealthCheckResult( - status=HealthStatus.UNHEALTHY, - check_name="inventory_access", - message="No ad units are accessible", - details={"inaccessible": inaccessible_units}, - duration_ms=(time.time() - start_time) * 1000, - ) + return HealthCheckResult( + status=HealthStatus.UNHEALTHY, + check_name="inventory_access", + message="No ad units are accessible", + details={"inaccessible": inaccessible_units}, + duration_ms=(time.time() - start_time) * 1000, + ) except Exception as e: return HealthCheckResult( @@ -388,14 +386,13 @@ def check_service_availability(self) -> HealthCheckResult: details=service_status, duration_ms=(time.time() - start_time) * 1000, ) - else: - return HealthCheckResult( - status=HealthStatus.DEGRADED, - check_name="service_availability", - message="Some GAM services are unavailable", - details=service_status, - duration_ms=(time.time() - start_time) * 1000, - ) + return HealthCheckResult( + status=HealthStatus.DEGRADED, + check_name="service_availability", + message="Some GAM services are unavailable", + details=service_status, + duration_ms=(time.time() - start_time) * 1000, + ) except Exception as e: return HealthCheckResult( diff --git a/src/adapters/gam/utils/logging.py b/src/adapters/gam/utils/logging.py index 6f859d47a0..1ed9448956 100644 --- a/src/adapters/gam/utils/logging.py +++ b/src/adapters/gam/utils/logging.py @@ -107,7 +107,7 @@ def _summarize_response(self, data: dict[str, Any]) -> dict[str, Any]: """Create a summary of response data for logging.""" if isinstance(data, dict): return {"id": data.get("id"), "name": data.get("name"), "status": data.get("status")} - elif isinstance(data, list): + if isinstance(data, list): return {"count": len(data), "first_id": data[0].get("id") if data else None} return {"type": str(type(data))} diff --git a/src/adapters/gam/utils/validation.py b/src/adapters/gam/utils/validation.py index 03ac404f16..a9db145e25 100644 --- a/src/adapters/gam/utils/validation.py +++ b/src/adapters/gam/utils/validation.py @@ -14,8 +14,6 @@ class GAMValidationError(Exception): """Exception raised when creative fails GAM validation.""" - pass - class GAMValidator: """Validator for GAM creative assets and content.""" @@ -311,9 +309,11 @@ def _validate_file_extension(self, url: str, format_type: str) -> list[str]: creative_type = "display" # default if any(file_path.endswith(ext) for ext in self.ALLOWED_EXTENSIONS["html5"]): creative_type = "html5" - elif any(file_path.endswith(ext) for ext in self.ALLOWED_EXTENSIONS["video"]): - creative_type = "video" - elif format_type and "video" in format_type.lower(): + elif ( + any(file_path.endswith(ext) for ext in self.ALLOWED_EXTENSIONS["video"]) + or format_type + and "video" in format_type.lower() + ): creative_type = "video" elif format_type and ("html5" in format_type.lower() or "rich_media" in format_type.lower()): creative_type = "html5" @@ -332,11 +332,10 @@ def _get_creative_type_from_asset(self, asset: dict[str, Any]) -> str: snippet_type = asset["snippet_type"] if snippet_type in ["vast_xml", "vast_url"]: return "vast" - else: - return "third_party_tag" - elif asset.get("template_variables"): + return "third_party_tag" + if asset.get("template_variables"): return "native" - elif asset.get("media_url") or asset.get("url"): + if asset.get("media_url") or asset.get("url"): # Determine type based on URL or format url = asset.get("media_url") or asset.get("url") or "" format_type = asset.get("format", "") @@ -348,14 +347,14 @@ def _get_creative_type_from_asset(self, asset: dict[str, Any]) -> str: or (format_type and "rich_media" in format_type.lower()) ): return "html5" - elif any(url.lower().endswith(ext) for ext in self.ALLOWED_EXTENSIONS["video"]): - return "video" - elif format_type and "video" in format_type.lower(): + if ( + any(url.lower().endswith(ext) for ext in self.ALLOWED_EXTENSIONS["video"]) + or format_type + and "video" in format_type.lower() + ): return "video" - else: - return "display" - else: return "display" + return "display" # Convenience function for easy import diff --git a/src/adapters/gam_data_freshness.py b/src/adapters/gam_data_freshness.py index ade223b5fe..3baaf7e1cf 100644 --- a/src/adapters/gam_data_freshness.py +++ b/src/adapters/gam_data_freshness.py @@ -174,12 +174,11 @@ def validate_and_log_freshness( if is_fresh: logger.info(f"Data is fresh for media buy {media_buy_id}: {reason}") return True - else: - logger.warning(f"Data not fresh for media buy {media_buy_id}: {reason}") + logger.warning(f"Data not fresh for media buy {media_buy_id}: {reason}") - # Check if we should retry - should_retry, retry_at = validator.should_retry_later(reporting_data, target_date or datetime.now(UTC)) - if should_retry and retry_at: - logger.info(f"Will retry media buy {media_buy_id} at {retry_at}") + # Check if we should retry + should_retry, retry_at = validator.should_retry_later(reporting_data, target_date or datetime.now(UTC)) + if should_retry and retry_at: + logger.info(f"Will retry media buy {media_buy_id} at {retry_at}") - return False + return False diff --git a/src/adapters/gam_inventory_discovery.py b/src/adapters/gam_inventory_discovery.py index 844f1ce2e6..b167495e43 100644 --- a/src/adapters/gam_inventory_discovery.py +++ b/src/adapters/gam_inventory_discovery.py @@ -409,8 +409,7 @@ def discover_labels(self, since: datetime | None = None) -> list[Label]: if "argument should be integer or bytes-like object" in str(e): logger.info("No labels found in GAM account (or empty result set)") return [] - else: - raise + raise logger.info(f"Discovered {len(discovered_labels)} labels") return discovered_labels diff --git a/src/adapters/gam_reporting_service.py b/src/adapters/gam_reporting_service.py index c553ae444c..95aae13b94 100644 --- a/src/adapters/gam_reporting_service.py +++ b/src/adapters/gam_reporting_service.py @@ -443,7 +443,7 @@ def _run_report(self, report_job: dict[str, Any]) -> list[dict[str, Any]]: status = self.report_service.getReportJobStatus(report_job_id) if status == "COMPLETED": break - elif status == "FAILED": + if status == "FAILED": raise Exception("GAM report job failed") # Log progress for long-running reports diff --git a/src/adapters/google_ad_manager.py b/src/adapters/google_ad_manager.py index ec40d60218..99bbcdd33e 100644 --- a/src/adapters/google_ad_manager.py +++ b/src/adapters/google_ad_manager.py @@ -671,11 +671,10 @@ def create_media_buy( creative_deadline_days=None, workflow_step_id=step_id, ) - else: - error_msg = "Failed to create manual order workflow step" - return CreateMediaBuyError( - errors=[Error(code="workflow_creation_failed", message=error_msg, details=None)], - ) + error_msg = "Failed to create manual order workflow step" + return CreateMediaBuyError( + errors=[Error(code="workflow_creation_failed", message=error_msg, details=None)], + ) # Automatic mode - create order directly # Use pre-loaded naming template, or fallback to default @@ -1015,19 +1014,18 @@ def add_creative_assets( ) ) return asset_statuses - else: - # Return failed statuses if workflow creation failed - asset_statuses = [] - for asset in assets: - asset_statuses.append( - AssetStatus( - asset_id=asset.get("asset_id", f"failed_{len(asset_statuses)}"), - status="failed", - message="Failed to create approval workflow step", - creative_id=None, - ) + # Return failed statuses if workflow creation failed + asset_statuses = [] + for asset in assets: + asset_statuses.append( + AssetStatus( + asset_id=asset.get("asset_id", f"failed_{len(asset_statuses)}"), + status="failed", + message="Failed to create approval workflow step", + creative_id=None, ) - return asset_statuses + ) + return asset_statuses # Automatic mode - process creatives directly # Pass placement_targeting_map for creative-level targeting (adcp#208) @@ -1491,16 +1489,15 @@ def update_media_buy( affected_packages=[], # List of package_ids affected by update implementation_date=today, ) - else: - return UpdateMediaBuyError( - errors=[ - Error( - code="workflow_creation_failed", - message="Failed to create approval workflow step", - details=None, - ) - ], - ) + return UpdateMediaBuyError( + errors=[ + Error( + code="workflow_creation_failed", + message="Failed to create approval workflow step", + details=None, + ) + ], + ) # Check for activate_order action with guaranteed items if action == "activate_order": @@ -1520,16 +1517,15 @@ def update_media_buy( implementation_date=today, workflow_step_id=step_id, ) - else: - return UpdateMediaBuyError( - errors=[ - Error( - code="activation_workflow_failed", - message=f"Cannot auto-activate order with guaranteed line items: {', '.join(item_types)}", - details=None, - ) - ], - ) + return UpdateMediaBuyError( + errors=[ + Error( + code="activation_workflow_failed", + message=f"Cannot auto-activate order with guaranteed line items: {', '.join(item_types)}", + details=None, + ) + ], + ) # Handle package budget updates if action == "update_package_budget" and package_id and budget is not None: diff --git a/src/adapters/mock_ad_server.py b/src/adapters/mock_ad_server.py index f006e8d3ce..7a688c28d0 100644 --- a/src/adapters/mock_ad_server.py +++ b/src/adapters/mock_ad_server.py @@ -334,10 +334,9 @@ def _simulate_approval(self) -> tuple[bool, str | None]: if approved: return True, None - else: - # Pick a random rejection reason - reason = random.choice(self.rejection_reasons) - return False, reason + # Pick a random rejection reason + reason = random.choice(self.rejection_reasons) + return False, reason def _schedule_async_completion(self, step_id: str, delay_ms: int): """Schedule automatic completion of an async task (for testing).""" @@ -566,7 +565,7 @@ def create_media_buy( if operation_mode == "async": return self._create_media_buy_async(request, packages, start_time, end_time) - elif operation_mode == "sync": + if operation_mode == "sync": return self._create_media_buy_sync_with_delay(request, packages, start_time, end_time, package_pricing_info) # Continue with immediate processing (default behavior) @@ -710,8 +709,8 @@ def _create_media_buy_immediate( auto_naming_enabled=auto_naming_enabled, tenant_id=tenant_id, ) - print( - f"[NAMING DEBUG] template={repr(order_name_template)}, has_promoted_offering={('promoted_offering' in context)}" + logger.debug( + f"[NAMING] template={order_name_template!r}, has_promoted_offering={'promoted_offering' in context}" ) order_name = apply_naming_template(order_name_template, context) @@ -864,7 +863,7 @@ def add_creative_assets( if operation_mode == "async": return self._add_creative_assets_async(media_buy_id, assets, today) - elif operation_mode == "sync": + if operation_mode == "sync": return self._add_creative_assets_sync_with_delay(media_buy_id, assets, today) # Continue with immediate processing (default behavior) @@ -959,7 +958,7 @@ def _add_creative_assets_sync_with_delay( # All rejected reasons = [reason if reason else "unknown" for _, reason in rejected_assets] raise Exception(f"All creatives rejected: {', '.join(reasons)}") - elif rejected_assets: + if rejected_assets: # Some rejected - log warnings but continue with approved ones for asset, reason in rejected_assets: self.log(f"⚠️ Creative {asset['id']} rejected: {reason}") @@ -1034,7 +1033,7 @@ def _add_creative_assets_immediate( self.log(f" ❓ Asking for field in creative '{creative_name}' - {reason}") results.append(AssetStatus(creative_id=asset["id"], status="pending")) continue - elif action_type == "approve": + if action_type == "approve": self.log(f" ✅ Approving creative '{creative_name}'") results.append(AssetStatus(creative_id=asset["id"], status="approved")) continue @@ -1087,7 +1086,7 @@ def get_media_buy_delivery( if self.strategy_context.force_error == "platform_error": self.log("[red]Simulating platform error[/red]") raise Exception("Platform connectivity error (simulated)") - elif self.strategy_context.force_error == "budget_exceeded": + if self.strategy_context.force_error == "budget_exceeded": self.log("[yellow]Simulating budget exceeded scenario[/yellow]") elif self.strategy_context.force_error == "low_delivery": self.log("[yellow]Simulating low delivery scenario[/yellow]") @@ -1517,32 +1516,30 @@ def _calculate_delivery_progress(self, profile: str, current_day: int, total_day # Slow ramp: 10% day 1, 30% day 3, linear to 100% at end if current_day <= 1: return 0.1 - elif current_day <= 3: + if current_day <= 3: return 0.3 - else: - # Linear from 30% to 100% over remaining days - days_after_3 = current_day - 3 - remaining_days = total_days - 3 - if remaining_days <= 0: - return 1.0 - return 0.3 + (days_after_3 / remaining_days) * 0.7 - - elif profile == "fast": + # Linear from 30% to 100% over remaining days + days_after_3 = current_day - 3 + remaining_days = total_days - 3 + if remaining_days <= 0: + return 1.0 + return 0.3 + (days_after_3 / remaining_days) * 0.7 + + if profile == "fast": # Fast delivery: 50% day 1, 100% day 2 if current_day <= 1: return 0.5 - else: - return 1.0 + return 1.0 - elif profile == "uneven": + if profile == "uneven": # Uneven with random spikes base_progress = current_day / total_days spike = random.uniform(-0.1, 0.2) # Random variance return min(1.0, max(0.0, base_progress + spike)) - else: # "normal" or unknown - # Linear pacing - return min(1.0, current_day / total_days) + # "normal" or unknown + # Linear pacing + return min(1.0, current_day / total_days) def _start_delivery_simulation( self, diff --git a/src/adapters/utils/timeout.py b/src/adapters/utils/timeout.py index 1ede846085..ed13c32c2e 100644 --- a/src/adapters/utils/timeout.py +++ b/src/adapters/utils/timeout.py @@ -19,8 +19,6 @@ class TimeoutError(Exception): """Raised when operation times out.""" - pass - def timeout(seconds: int = 300): """ diff --git a/src/admin/app.py b/src/admin/app.py index 4d304124f1..066e9afcbe 100644 --- a/src/admin/app.py +++ b/src/admin/app.py @@ -352,10 +352,10 @@ def enforce_admin_csrf(): # stay green. Tests that need to exercise the production # CSRF path (see test_admin_csrf_global.py) flip TESTING # off explicitly via a per-test fixture. - return None + return if request.method in _CSRF_SAFE_METHODS: - return None + return # CSRF can only be mounted when the victim's browser # auto-attaches a session cookie to the attacker's cross- @@ -374,7 +374,7 @@ def enforce_admin_csrf(): # be forged by an attacker on a cookie-authed admin route. session_cookie_name = app.config.get("SESSION_COOKIE_NAME", "session") if not request.cookies.get(session_cookie_name): - return None + return candidate = request.headers.get("Origin") or request.headers.get("Referer") or "" expected = request.host_url.rstrip("/") @@ -386,7 +386,7 @@ def enforce_admin_csrf(): and submitted_token and hmac.compare_digest(expected_token, submitted_token) ): - return None + return logger.warning( "Refusing admin %s to %s with missing/invalid CSRF token — origin=%r referer=%r host_url=%r", @@ -425,12 +425,12 @@ def warn_embedded_missing_prefix(): from flask import request if app.config.get("TESTING"): - return None + return if not request.headers.get("X-Identity-Subject"): - return None + return has_prefix = bool(request.headers.get("X-Forwarded-Prefix") or request.headers.get("X-Script-Name")) if has_prefix: - return None + return logger.warning( "[EMBEDDED_PREFIX_MISSING] Embedded auth present (X-Identity-Subject set) " "but no X-Forwarded-Prefix/X-Script-Name header on %s %s — generated " diff --git a/src/admin/blueprints/adapters.py b/src/admin/blueprints/adapters.py index dc79ad8081..af67d7dee1 100644 --- a/src/admin/blueprints/adapters.py +++ b/src/admin/blueprints/adapters.py @@ -330,8 +330,7 @@ def get_adapter_capabilities(adapter_type, tenant_id, **kwargs): if schemas.capabilities: return jsonify(asdict(schemas.capabilities)) - else: - return jsonify({}) + return jsonify({}) @adapters_bp.route("/api/tenant//adapters//check-permissions", methods=["POST"]) @@ -826,8 +825,7 @@ def test_broadstreet_connection(tenant_id, **kwargs): "network_id": network_id, } ) - else: - return jsonify({"success": False, "error": "Could not retrieve network information"}) + return jsonify({"success": False, "error": "Could not retrieve network information"}) except Exception as e: logger.error(f"Broadstreet connection test failed: {e}", exc_info=True) diff --git a/src/admin/blueprints/api.py b/src/admin/blueprints/api.py index 5587398650..577e34c87c 100644 --- a/src/admin/blueprints/api.py +++ b/src/admin/blueprints/api.py @@ -172,7 +172,7 @@ def get_product_suggestions(tenant_id): if max_cpm: if product.get("cpm") and product["cpm"] > max_cpm: continue - elif product.get("price_guidance"): + if product.get("price_guidance"): if product["price_guidance"]["min"] > max_cpm: continue diff --git a/src/admin/blueprints/auth.py b/src/admin/blueprints/auth.py index 74dd4ba4fe..6f8b5f3f03 100644 --- a/src/admin/blueprints/auth.py +++ b/src/admin/blueprints/auth.py @@ -105,8 +105,7 @@ def get_oauth_config(): if provider_url: logger.info(f"Using {provider} OAuth provider") return client_id, client_secret, provider_url, scopes - else: - logger.warning(f"Provider '{provider}' requires OAUTH_DISCOVERY_URL to be set") + logger.warning(f"Provider '{provider}' requires OAUTH_DISCOVERY_URL to be set") # Option 3: Google-specific environment variables (backwards compatible) google_client_id = os.environ.get("GOOGLE_CLIENT_ID") @@ -164,13 +163,12 @@ def init_oauth(app): app.oauth_provider = get_oauth_provider_name() logger.info(f"OAuth initialized with provider: {app.oauth_provider}") return oauth - else: - logger.warning( - "OAuth not configured - authentication will not work. " - "Set OAUTH_DISCOVERY_URL + OAUTH_CLIENT_ID + OAUTH_CLIENT_SECRET for generic OIDC, " - "or GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET for Google OAuth." - ) - return None + logger.warning( + "OAuth not configured - authentication will not work. " + "Set OAUTH_DISCOVERY_URL + OAUTH_CLIENT_ID + OAUTH_CLIENT_SECRET for generic OIDC, " + "or GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET for Google OAuth." + ) + return None def _test_auth_env_enabled() -> bool: @@ -949,9 +947,8 @@ def test_auth(): fallback=url_for("tenants.dashboard", tenant_id=tenant_id), ) return redirect(next_url) - else: - next_url = _safe_redirect(session.pop("login_next_url", None), fallback=url_for("core.index")) - return redirect(next_url) + next_url = _safe_redirect(session.pop("login_next_url", None), fallback=url_for("core.index")) + return redirect(next_url) flash("Invalid test credentials", "error") return redirect(request.referrer or url_for("auth.login")) @@ -1166,10 +1163,9 @@ def gam_callback(): # Redirect back to tenant settings if external_domain and os.environ.get("PRODUCTION") == "true": return redirect(f"https://{external_domain}/admin/tenant/{tenant_id}/settings") - elif originating_host and os.environ.get("PRODUCTION") == "true": + if originating_host and os.environ.get("PRODUCTION") == "true": return redirect(f"https://{originating_host}/admin/tenant/{tenant_id}/settings") - else: - return redirect(url_for("tenants.tenant_settings", tenant_id=tenant_id)) + return redirect(url_for("tenants.tenant_settings", tenant_id=tenant_id)) except Exception as e: logger.error(f"Error in GAM OAuth callback: {e}", exc_info=True) diff --git a/src/admin/blueprints/authorized_properties.py b/src/admin/blueprints/authorized_properties.py index aba449a596..84404e6b42 100644 --- a/src/admin/blueprints/authorized_properties.py +++ b/src/admin/blueprints/authorized_properties.py @@ -248,12 +248,11 @@ def _construct_agent_url(tenant_id: str, request: Any) -> str: url = f"https://{virtual_host}" logger.info(f"🌐 Production: using virtual_host -> {url}") return url - else: - # Fallback to subdomain pattern - tenant_url = get_tenant_url(subdomain) - if tenant_url: - logger.info(f"🌐 Production: using subdomain pattern -> {tenant_url}") - return tenant_url + # Fallback to subdomain pattern + tenant_url = get_tenant_url(subdomain) + if tenant_url: + logger.info(f"🌐 Production: using subdomain pattern -> {tenant_url}") + return tenant_url # If SALES_AGENT_DOMAIN not configured, fall through to development mode # For development, use MCP server port diff --git a/src/admin/blueprints/creative_agents.py b/src/admin/blueprints/creative_agents.py index d316d2b798..5299ccab86 100644 --- a/src/admin/blueprints/creative_agents.py +++ b/src/admin/blueprints/creative_agents.py @@ -284,16 +284,15 @@ def test_creative_agent(tenant_id, agent_id): "sample_formats": [f.name for f in formats[:5]], } ) - else: - return ( - jsonify( - { - "success": False, - "error": "Agent returned no formats", - } - ), - 400, - ) + return ( + jsonify( + { + "success": False, + "error": "Agent returned no formats", + } + ), + 400, + ) finally: loop.close() diff --git a/src/admin/blueprints/creatives.py b/src/admin/blueprints/creatives.py index 1ba75cdf80..48fef8a402 100644 --- a/src/admin/blueprints/creatives.py +++ b/src/admin/blueprints/creatives.py @@ -1285,31 +1285,30 @@ def run_review_in_thread(): ai_review_total.labels(tenant_id=tenant_id, decision="approved", policy_triggered="auto_approve").inc() ai_review_confidence.labels(tenant_id=tenant_id, decision="approved").observe(confidence_score) return result_dict - else: - result_dict = { - "status": "pending_review", - "reason": f"AI recommended approval with {confidence_score:.0%} confidence (below {auto_approve_threshold:.0%} threshold). Human review recommended.", - "confidence": confidence_str, - "confidence_score": confidence_score, - "policy_triggered": "low_confidence_approval", - "ai_recommendation": "approve", - "ai_reason": review_result.reason, - } - _create_review_record( - db_session, - creative_id, - tenant_id, - result_dict, - principal_id=creative.principal_id, - ) - # Record metrics - ai_review_total.labels( - tenant_id=tenant_id, decision="pending_review", policy_triggered="low_confidence_approval" - ).inc() - ai_review_confidence.labels(tenant_id=tenant_id, decision="pending_review").observe(confidence_score) - return result_dict + result_dict = { + "status": "pending_review", + "reason": f"AI recommended approval with {confidence_score:.0%} confidence (below {auto_approve_threshold:.0%} threshold). Human review recommended.", + "confidence": confidence_str, + "confidence_score": confidence_score, + "policy_triggered": "low_confidence_approval", + "ai_recommendation": "approve", + "ai_reason": review_result.reason, + } + _create_review_record( + db_session, + creative_id, + tenant_id, + result_dict, + principal_id=creative.principal_id, + ) + # Record metrics + ai_review_total.labels( + tenant_id=tenant_id, decision="pending_review", policy_triggered="low_confidence_approval" + ).inc() + ai_review_confidence.labels(tenant_id=tenant_id, decision="pending_review").observe(confidence_score) + return result_dict - elif "REJECT" in decision: + if "REJECT" in decision: # AI wants to reject - check confidence threshold if confidence_score >= auto_reject_threshold: result_dict = { @@ -1330,29 +1329,28 @@ def run_review_in_thread(): ai_review_total.labels(tenant_id=tenant_id, decision="rejected", policy_triggered="auto_reject").inc() ai_review_confidence.labels(tenant_id=tenant_id, decision="rejected").observe(confidence_score) return result_dict - else: - result_dict = { - "status": "pending_review", - "reason": f"AI recommended rejection with {confidence_score:.0%} confidence (below {auto_reject_threshold:.0%} threshold). Human review recommended.", - "confidence": confidence_str, - "confidence_score": confidence_score, - "policy_triggered": "uncertain_rejection", - "ai_recommendation": "reject", - "ai_reason": review_result.reason, - } - _create_review_record( - db_session, - creative_id, - tenant_id, - result_dict, - principal_id=creative.principal_id, - ) - # Record metrics - ai_review_total.labels( - tenant_id=tenant_id, decision="pending_review", policy_triggered="uncertain_rejection" - ).inc() - ai_review_confidence.labels(tenant_id=tenant_id, decision="pending_review").observe(confidence_score) - return result_dict + result_dict = { + "status": "pending_review", + "reason": f"AI recommended rejection with {confidence_score:.0%} confidence (below {auto_reject_threshold:.0%} threshold). Human review recommended.", + "confidence": confidence_str, + "confidence_score": confidence_score, + "policy_triggered": "uncertain_rejection", + "ai_recommendation": "reject", + "ai_reason": review_result.reason, + } + _create_review_record( + db_session, + creative_id, + tenant_id, + result_dict, + principal_id=creative.principal_id, + ) + # Record metrics + ai_review_total.labels( + tenant_id=tenant_id, decision="pending_review", policy_triggered="uncertain_rejection" + ).inc() + ai_review_confidence.labels(tenant_id=tenant_id, decision="pending_review").observe(confidence_score) + return result_dict # Default: uncertain or "REQUIRE HUMAN APPROVAL" result_dict = { diff --git a/src/admin/blueprints/gam.py b/src/admin/blueprints/gam.py index c98cbee00f..3ef58d583e 100644 --- a/src/admin/blueprints/gam.py +++ b/src/admin/blueprints/gam.py @@ -303,7 +303,6 @@ def detect_gam_network(tenant_id): except AttributeError as e: # getAllNetworks might not be available in this GAM version logger.info(f"getAllNetworks AttributeError: {e}") - pass # If getAllNetworks didn't work, we can't get the network without a network_code logger.warning("getAllNetworks() returned empty/None or AttributeError - cannot auto-detect network") @@ -822,8 +821,7 @@ def get_service_account_email(tenant_id): if email: return jsonify({"success": True, "service_account_email": email}) - else: - return jsonify({"success": True, "service_account_email": None, "message": "No service account created"}) + return jsonify({"success": True, "service_account_email": None, "message": "No service account created"}) except Exception as e: logger.error(f"Error getting service account email: {e}", exc_info=True) diff --git a/src/admin/blueprints/inventory.py b/src/admin/blueprints/inventory.py index f4a1916673..414a190108 100644 --- a/src/admin/blueprints/inventory.py +++ b/src/admin/blueprints/inventory.py @@ -1399,48 +1399,46 @@ def get_inventory_tree(tenant_id): } ) - else: - # --- Mode 1: Root nodes only (lazy loading) --- - # Roots: parent_id is null or missing in JSONB metadata. - # PostgreSQL ->> returns NULL for both cases (null value and missing key). - root_stmt = ( - select(GAMInventory) - .where( - *base_where, - GAMInventory.inventory_metadata["parent_id"].as_string().is_(None), - ) - .order_by(GAMInventory.name) + # --- Mode 1: Root nodes only (lazy loading) --- + # Roots: parent_id is null or missing in JSONB metadata. + # PostgreSQL ->> returns NULL for both cases (null value and missing key). + root_stmt = ( + select(GAMInventory) + .where( + *base_where, + GAMInventory.inventory_metadata["parent_id"].as_string().is_(None), ) - roots, truncated = execute_limited(db_session, root_stmt, _TREE_LIMIT) + .order_by(GAMInventory.name) + ) + roots, truncated = execute_limited(db_session, root_stmt, _TREE_LIMIT) - root_units = [_unit_to_dict(u) for u in roots] - logger.info( - f"Returning {len(root_units)} root units " - f"(total active: {total_active_count}, truncated: {truncated})" - ) + root_units = [_unit_to_dict(u) for u in roots] + logger.info( + f"Returning {len(root_units)} root units (total active: {total_active_count}, truncated: {truncated})" + ) - stats = _get_inventory_stats(db_session, tenant_id) + stats = _get_inventory_stats(db_session, tenant_id) - result = jsonify( - { - "root_units": root_units, - "total_units": len(root_units), - "total_active_count": total_active_count, - "truncated": truncated, - "root_count": len(root_units), - "search_active": False, - "matching_count": 0, - **stats, - } - ) + result = jsonify( + { + "root_units": root_units, + "total_units": len(root_units), + "total_active_count": total_active_count, + "truncated": truncated, + "root_count": len(root_units), + "search_active": False, + "matching_count": 0, + **stats, + } + ) - if cache: - import time + if cache: + import time - cache.set(cache_key, result, timeout=300) - cache.set(cache_time_key, time.time(), timeout=300) + cache.set(cache_key, result, timeout=300) + cache.set(cache_time_key, time.time(), timeout=300) - return result + return result except Exception as e: logger.error(f"Error building inventory tree for tenant {tenant_id}: {e}", exc_info=True) diff --git a/src/admin/blueprints/oidc.py b/src/admin/blueprints/oidc.py index fb9f8c361e..155ce11800 100644 --- a/src/admin/blueprints/oidc.py +++ b/src/admin/blueprints/oidc.py @@ -146,8 +146,7 @@ def enable(tenant_id: str): "oidc_enabled": actual_enabled, } ) - else: - return jsonify({"error": "Cannot enable OIDC. Please test the configuration first."}), 400 + return jsonify({"error": "Cannot enable OIDC. Please test the configuration first."}), 400 @oidc_bp.route("/tenant//disable", methods=["POST"]) diff --git a/src/admin/blueprints/products.py b/src/admin/blueprints/products.py index 204151f47a..b9b66a3562 100644 --- a/src/admin/blueprints/products.py +++ b/src/admin/blueprints/products.py @@ -162,8 +162,7 @@ def _format_id_to_display_name(format_id: str) -> str: # Add dimensions back if found if size_match: return f"{base_name} ({size_match.group(0)})" - else: - return base_name + return base_name def _format_error_to_dict(error: Any) -> dict[str, Any]: @@ -310,7 +309,7 @@ def parse_pricing_options_from_form(form_data: dict) -> list[dict]: # Find all pricing option indices by scanning form keys # This handles non-contiguous indices (e.g., 0 removed, only 1 exists) indices = set() - for key in form_data.keys(): + for key in form_data: if key.startswith("pricing_model_"): try: idx = int(key.replace("pricing_model_", "")) @@ -774,21 +773,20 @@ def _render_add_product_form(tenant_id, tenant, adapter_type, currencies, form_d principals=principals_list, form_data=form_data, # Preserve form data on error ) - else: - # For Mock and other adapters - use unified template - formats = get_creative_formats(tenant_id=tenant_id) - return render_template( - "add_product.html", - tenant_id=tenant_id, - tenant=tenant, - adapter_type=adapter_type, - formats=formats, - authorized_properties=properties_list, - property_tags=property_tags, - currencies=currencies, - principals=principals_list, - form_data=form_data, # Preserve form data on error - ) + # For Mock and other adapters - use unified template + formats = get_creative_formats(tenant_id=tenant_id) + return render_template( + "add_product.html", + tenant_id=tenant_id, + tenant=tenant, + adapter_type=adapter_type, + formats=formats, + authorized_properties=properties_list, + property_tags=property_tags, + currencies=currencies, + principals=principals_list, + form_data=form_data, # Preserve form data on error + ) @products_bp.route("/add", methods=["GET", "POST"]) @@ -2209,21 +2207,20 @@ def edit_product(tenant_id, product_id): authorized_properties=authorized_properties_list, selected_publisher_properties=selected_publisher_properties, ) - else: - # For non-GAM adapters - use unified edit template - # Reload tenant for template context (measurement_providers, etc.) - tenant = db_session.scalars(select(Tenant).filter_by(tenant_id=tenant_id)).first() - return render_template( - "edit_product.html", - tenant_id=tenant_id, - tenant=tenant, - adapter_type=adapter_type, - product=product_dict, - currencies=currencies, - principals=principals_list, - authorized_properties=authorized_properties_list, - selected_publisher_properties=selected_publisher_properties, - ) + # For non-GAM adapters - use unified edit template + # Reload tenant for template context (measurement_providers, etc.) + tenant = db_session.scalars(select(Tenant).filter_by(tenant_id=tenant_id)).first() + return render_template( + "edit_product.html", + tenant_id=tenant_id, + tenant=tenant, + adapter_type=adapter_type, + product=product_dict, + currencies=currencies, + principals=principals_list, + authorized_properties=authorized_properties_list, + selected_publisher_properties=selected_publisher_properties, + ) except Exception as e: logger.error(f"Error editing product: {e}", exc_info=True) @@ -2440,17 +2437,16 @@ def assign_inventory_to_product(tenant_id, product_id): "inventory_name": inventory.name, } ) - else: - return ( - jsonify( - { - "message": "Inventory assigned to product successfully", - "mapping_id": mapping.id, - "inventory_name": inventory.name, - } - ), - 201, - ) + return ( + jsonify( + { + "message": "Inventory assigned to product successfully", + "mapping_id": mapping.id, + "inventory_name": inventory.name, + } + ), + 201, + ) except Exception as e: logger.error(f"Error assigning inventory to product: {e}", exc_info=True) diff --git a/src/admin/blueprints/settings.py b/src/admin/blueprints/settings.py index ca455e6415..f92ba4c30d 100644 --- a/src/admin/blueprints/settings.py +++ b/src/admin/blueprints/settings.py @@ -1494,11 +1494,10 @@ def check_approximated_domain_status(tenant_id): "target_address": domain_data.get("target_address"), } ) - elif response.status_code == 404: + if response.status_code == 404: return jsonify({"success": True, "registered": False}) - else: - logger.error(f"Approximated API error: {response.status_code} - {response.text}") - return jsonify({"success": False, "error": f"API error: {response.status_code}"}), 500 + logger.error(f"Approximated API error: {response.status_code} - {response.text}") + return jsonify({"success": False, "error": f"API error: {response.status_code}"}), 500 except Exception as e: logger.error(f"Error checking domain status: {e}", exc_info=True) @@ -1551,14 +1550,13 @@ def register_approximated_domain(tenant_id): if response.status_code in (200, 201): logger.info(f"✅ Registered domain with Approximated: {domain}") return jsonify({"success": True, "message": f"Domain {domain} registered successfully"}) - elif response.status_code == 409: + if response.status_code == 409: # Already exists - that's OK logger.info(f"✅ Domain already registered: {domain}") return jsonify({"success": True, "message": f"Domain {domain} already registered"}) - else: - error_msg = f"Approximated API error: {response.status_code} - {response.text}" - logger.error(error_msg) - return jsonify({"success": False, "error": error_msg}), response.status_code + error_msg = f"Approximated API error: {response.status_code} - {response.text}" + logger.error(error_msg) + return jsonify({"success": False, "error": error_msg}), response.status_code except Exception as e: logger.error(f"Error registering domain: {e}", exc_info=True) @@ -1596,14 +1594,13 @@ def unregister_approximated_domain(tenant_id): if response.status_code in (200, 204): logger.info(f"✅ Unregistered domain from Approximated: {domain}") return jsonify({"success": True, "message": f"Domain {domain} unregistered successfully"}) - elif response.status_code == 404: + if response.status_code == 404: # Already gone - that's OK logger.info(f"✅ Domain already unregistered: {domain}") return jsonify({"success": True, "message": f"Domain {domain} was not registered"}) - else: - error_msg = f"Approximated API error: {response.status_code} - {response.text}" - logger.error(error_msg) - return jsonify({"success": False, "error": error_msg}), response.status_code + error_msg = f"Approximated API error: {response.status_code} - {response.text}" + logger.error(error_msg) + return jsonify({"success": False, "error": error_msg}), response.status_code except Exception as e: logger.error(f"Error unregistering domain: {e}", exc_info=True) @@ -1643,9 +1640,8 @@ def get_approximated_token(tenant_id): token_data = response.json() logger.info(f"Approximated API response: {token_data}") return jsonify({"success": True, "token": token_data.get("token"), "proxy_ip": approximated_proxy_ip}) - else: - logger.error(f"Approximated API error: {response.status_code} - {response.text}") - return jsonify({"success": False, "error": f"API error: {response.status_code}"}), response.status_code + logger.error(f"Approximated API error: {response.status_code} - {response.text}") + return jsonify({"success": False, "error": f"API error: {response.status_code}"}), response.status_code except Exception as e: logger.error(f"Error generating Approximated token: {e}", exc_info=True) diff --git a/src/admin/blueprints/signals_agents.py b/src/admin/blueprints/signals_agents.py index 1c2ce76f30..e1bb7a9560 100644 --- a/src/admin/blueprints/signals_agents.py +++ b/src/admin/blueprints/signals_agents.py @@ -308,16 +308,15 @@ def test_signals_agent(tenant_id, agent_id): "signal_count": result.get("signal_count", 0), } ) - else: - return ( - jsonify( - { - "success": False, - "error": result.get("error", "Connection failed"), - } - ), - 400, - ) + return ( + jsonify( + { + "success": False, + "error": result.get("error", "Connection failed"), + } + ), + 400, + ) except Exception as e: logger.error(f"Error testing signals agent: {e}", exc_info=True) diff --git a/src/admin/blueprints/tenants.py b/src/admin/blueprints/tenants.py index 2f86e3744a..7b69a31f3e 100644 --- a/src/admin/blueprints/tenants.py +++ b/src/admin/blueprints/tenants.py @@ -499,13 +499,10 @@ def test_slack(tenant_id): if response.status_code == 200: return jsonify({"success": True, "message": "Test message sent successfully"}) - else: - return ( - jsonify( - {"success": False, "error": f"Slack returned status {response.status_code}: {response.text}"} - ), - 400, - ) + return ( + jsonify({"success": False, "error": f"Slack returned status {response.status_code}: {response.text}"}), + 400, + ) except requests.exceptions.RequestException as e: logger.error(f"Error testing Slack webhook: {e}") diff --git a/src/admin/services/dashboard_service.py b/src/admin/services/dashboard_service.py index 2b99539eb5..393498606b 100644 --- a/src/admin/services/dashboard_service.py +++ b/src/admin/services/dashboard_service.py @@ -304,13 +304,12 @@ def _format_relative_time(self, timestamp) -> str: if delta.days > 0: if delta.days == 1: return "1 day ago" - elif delta.days < 7: + if delta.days < 7: return f"{delta.days} days ago" - elif delta.days < 30: + if delta.days < 30: weeks = delta.days // 7 return f"{weeks} week{'s' if weeks != 1 else ''} ago" - else: - return timestamp.strftime("%Y-%m-%d") + return timestamp.strftime("%Y-%m-%d") hours = delta.seconds // 3600 if hours > 0: diff --git a/src/admin/sync_api.py b/src/admin/sync_api.py index 1aa6cbaa3c..daad73da37 100644 --- a/src/admin/sync_api.py +++ b/src/admin/sync_api.py @@ -91,7 +91,7 @@ def trigger_sync(tenant_id: str) -> tuple[Response, int]: if recent_sync: if recent_sync.status == "running": return jsonify({"message": "Sync already in progress", "sync_id": recent_sync.sync_id}), 409 - elif recent_sync.completed_at: + if recent_sync.completed_at: return ( jsonify( { diff --git a/src/admin/utils/helpers.py b/src/admin/utils/helpers.py index 4985aea234..80fbcf1d26 100644 --- a/src/admin/utils/helpers.py +++ b/src/admin/utils/helpers.py @@ -97,10 +97,7 @@ def get_tenant_config_from_db(tenant_id): ) elif adapter_type == "mock": adapter_config[adapter_type]["dry_run"] = adapter_obj.mock_dry_run or False - elif adapter_type in {"triton", "triton_digital"}: - if adapter_obj.config_json: - adapter_config[adapter_type].update(adapter_obj.config_json) - elif adapter_type == "freewheel": + elif adapter_type in {"triton", "triton_digital"} or adapter_type == "freewheel": if adapter_obj.config_json: adapter_config[adapter_type].update(adapter_obj.config_json) @@ -825,11 +822,11 @@ def translate_node(node): if len(children) == 1: return children[0] - elif len(children) > 1: + if len(children) > 1: return {operator: children} return None - elif "keyId" in node: + if "keyId" in node: # This is a key-value targeting node key_id = str(node["keyId"]) key_name = key_mappings.get(key_id, f"key_{key_id}") @@ -845,10 +842,9 @@ def translate_node(node): if operator == "IS": return {"key": key_name, "in": values} - elif operator == "IS_NOT": + if operator == "IS_NOT": return {"key": key_name, "not_in": values} - else: - return {"key": key_name, "operator": operator, "values": values} + return {"key": key_name, "operator": operator, "values": values} elif hasattr(node, "logicalOperator"): # Handle SOAP/object-based nodes (from GAM) @@ -862,7 +858,7 @@ def translate_node(node): if len(children) == 1: return children[0] - elif len(children) > 1: + if len(children) > 1: return {operator: children} return None @@ -882,10 +878,9 @@ def translate_node(node): if operator == "IS": return {"key": key_name, "in": values} - elif operator == "IS_NOT": + if operator == "IS_NOT": return {"key": key_name, "not_in": values} - else: - return {"key": key_name, "operator": operator, "values": values} + return {"key": key_name, "operator": operator, "values": values} return None diff --git a/src/core/async_patterns.py b/src/core/async_patterns.py index b29259e3a4..8071253d23 100644 --- a/src/core/async_patterns.py +++ b/src/core/async_patterns.py @@ -126,12 +126,11 @@ def is_async_operation(operation_name: str) -> bool: if operation_name in async_operations: return True - elif operation_name in sync_operations: + if operation_name in sync_operations: return False - else: - # Default: if it starts with "create", "update", "delete", it's probably async - prefixes = ["create", "update", "delete", "submit", "process", "generate"] - return any(operation_name.startswith(prefix) for prefix in prefixes) + # Default: if it starts with "create", "update", "delete", it's probably async + prefixes = ["create", "update", "delete", "submit", "process", "generate"] + return any(operation_name.startswith(prefix) for prefix in prefixes) # Specific async task types for our domain diff --git a/src/core/auth.py b/src/core/auth.py index 7d79b957be..768b6d67aa 100644 --- a/src/core/auth.py +++ b/src/core/auth.py @@ -273,13 +273,12 @@ def get_principal_from_context( f"The token may be expired, revoked, or associated with a different tenant.", details={"error_code": "INVALID_AUTH_TOKEN"}, ) - else: - # For discovery endpoints, treat invalid token like missing token - logger.debug( - "Invalid token for tenant '%s' - continuing without auth (discovery endpoint)", - requested_tenant_id or "any", - ) - return (None, tenant_context) + # For discovery endpoints, treat invalid token like missing token + logger.debug( + "Invalid token for tenant '%s' - continuing without auth (discovery endpoint)", + requested_tenant_id or "any", + ) + return (None, tenant_context) # If tenant_context wasn't set by header detection, use tenant discovered from token if not tenant_context and token_tenant: diff --git a/src/core/auth_utils.py b/src/core/auth_utils.py index 9fb89935a6..b8a124cc93 100644 --- a/src/core/auth_utils.py +++ b/src/core/auth_utils.py @@ -46,35 +46,31 @@ def _lookup_principal(session): return f"{tenant_id}_admin", None return None, None - else: - # No tenant specified - search globally - stmt = select(Principal).filter_by(access_token=token) - principal = session.scalars(stmt).first() - logger.debug(f"[AUTH] Looking up principal with token: {token[:20]}...") - if principal: - logger.info(f"[AUTH] Principal found: {principal.principal_id}, tenant_id={principal.tenant_id}") - # Found principal - look up tenant to return - stmt = select(Tenant).filter_by(tenant_id=principal.tenant_id, is_active=True) - tenant = session.scalars(stmt).first() - if tenant: - logger.info(f"[AUTH] Tenant found: {tenant.tenant_id}, is_active={tenant.is_active}") - from src.core.utils.tenant_utils import serialize_tenant_to_dict - - tenant_dict = serialize_tenant_to_dict(tenant) - return principal.principal_id, tenant_dict - else: - logger.error( - f"[AUTH] ERROR: Tenant NOT FOUND for tenant_id={principal.tenant_id} with is_active=True" - ) - # Try without is_active filter to see if tenant exists but is_active is wrong - stmt_debug = select(Tenant).filter_by(tenant_id=principal.tenant_id) - tenant_debug = session.scalars(stmt_debug).first() - if tenant_debug: - logger.warning(f"[AUTH] DEBUG: Tenant EXISTS but is_active={tenant_debug.is_active}") - else: - logger.warning("[AUTH] DEBUG: Tenant does not exist at all") + # No tenant specified - search globally + stmt = select(Principal).filter_by(access_token=token) + principal = session.scalars(stmt).first() + logger.debug(f"[AUTH] Looking up principal with token: {token[:20]}...") + if principal: + logger.info(f"[AUTH] Principal found: {principal.principal_id}, tenant_id={principal.tenant_id}") + # Found principal - look up tenant to return + stmt = select(Tenant).filter_by(tenant_id=principal.tenant_id, is_active=True) + tenant = session.scalars(stmt).first() + if tenant: + logger.info(f"[AUTH] Tenant found: {tenant.tenant_id}, is_active={tenant.is_active}") + from src.core.utils.tenant_utils import serialize_tenant_to_dict + + tenant_dict = serialize_tenant_to_dict(tenant) + return principal.principal_id, tenant_dict + logger.error(f"[AUTH] ERROR: Tenant NOT FOUND for tenant_id={principal.tenant_id} with is_active=True") + # Try without is_active filter to see if tenant exists but is_active is wrong + stmt_debug = select(Tenant).filter_by(tenant_id=principal.tenant_id) + tenant_debug = session.scalars(stmt_debug).first() + if tenant_debug: + logger.warning(f"[AUTH] DEBUG: Tenant EXISTS but is_active={tenant_debug.is_active}") else: - logger.error(f"[AUTH] ERROR: Principal NOT FOUND for token {token[:20]}...") + logger.warning("[AUTH] DEBUG: Tenant does not exist at all") + else: + logger.error(f"[AUTH] ERROR: Principal NOT FOUND for token {token[:20]}...") return None, None diff --git a/src/core/context_manager.py b/src/core/context_manager.py index e1963e3534..ae56561216 100644 --- a/src/core/context_manager.py +++ b/src/core/context_manager.py @@ -134,8 +134,7 @@ def get_or_create_context( if context_id: return self.get_context(context_id) - else: - return self.create_context(tenant_id, principal_id) + return self.create_context(tenant_id, principal_id) def update_activity(self, context_id: str) -> None: """Update the last activity timestamp for a context. @@ -487,7 +486,6 @@ def set_tool_state(self, context_id: str, tool_name: str, state: dict[str, Any]) """ # For now, we can store this in the latest workflow step's response_data # or create a dedicated notification step - pass def get_context_status(self, context_id: str) -> dict[str, Any]: """Get the overall status of a context by checking its workflow steps. diff --git a/src/core/creative_agent_registry.py b/src/core/creative_agent_registry.py index 14184b9c40..b2408c980e 100644 --- a/src/core/creative_agent_registry.py +++ b/src/core/creative_agent_registry.py @@ -368,10 +368,10 @@ async def _fetch_formats_from_agent( return formats - elif result.status == "submitted": + if result.status == "submitted": raise AdCPAdapterError(f"Unexpected submitted status for list_creative_formats from {agent.name}") - elif result.status == "failed": + if result.status == "failed": # Log detailed error information for debugging # Use getattr for safe access in case response structure varies error_msg = ( @@ -406,8 +406,7 @@ async def _fetch_formats_from_agent( logger.debug(f"Debug info: {debug_info}") raise AdCPAdapterError(f"Creative agent format fetch failed: {error_msg}") - else: - raise AdCPAdapterError(f"Unexpected result status from {agent.name}: {result.status}") + raise AdCPAdapterError(f"Unexpected result status from {agent.name}: {result.status}") except ADCPAuthenticationError as e: logger.error(f"Authentication failed for creative agent {agent.name}: {e.message}") diff --git a/src/core/database/db_config.py b/src/core/database/db_config.py index f8486cc603..096ce9fc23 100644 --- a/src/core/database/db_config.py +++ b/src/core/database/db_config.py @@ -97,9 +97,8 @@ def get_connection_string() -> str: if host.startswith("/"): # Unix socket: put path in query string, not in authority return f"postgresql://{auth}@/{config['database']}?host={host}" - else: - # TCP connection: standard format - return f"postgresql://{auth}@{host}:{config['port']}/{config['database']}?sslmode={config['sslmode']}" + # TCP connection: standard format + return f"postgresql://{auth}@{host}:{config['port']}/{config['database']}?sslmode={config['sslmode']}" class DatabaseConnection: diff --git a/src/core/database/models.py b/src/core/database/models.py index a84a148ee7..87c6310c30 100644 --- a/src/core/database/models.py +++ b/src/core/database/models.py @@ -98,8 +98,6 @@ def _encrypt_optional_secret(value: str | None) -> str | None: class Base(DeclarativeBase): """Base class for all SQLAlchemy models using SQLAlchemy 2.0 declarative style.""" - pass - class Tenant(Base, JSONValidatorMixin): __tablename__ = "tenants" @@ -531,7 +529,7 @@ def effective_properties(self) -> list[dict] | None: # Convert product's authorization to AdCP publisher_properties format if self.properties: return ensure_selection_type(self.properties) - elif self.property_ids: + if self.property_ids: # AdCP 2.0.0 by_id variant # Get publisher_domain from tenant (use subdomain or virtual_host) if hasattr(self, "tenant") and self.tenant: @@ -541,7 +539,7 @@ def effective_properties(self) -> list[dict] | None: return [ {"publisher_domain": publisher_domain, "property_ids": self.property_ids, "selection_type": "by_id"} ] - elif self.property_tags: + if self.property_tags: # AdCP 2.0.0 by_tag variant # Get publisher_domain from tenant (use subdomain or virtual_host) if hasattr(self, "tenant") and self.tenant: diff --git a/src/core/helpers/adapter_helpers.py b/src/core/helpers/adapter_helpers.py index 4bded0f725..30b87a0d68 100644 --- a/src/core/helpers/adapter_helpers.py +++ b/src/core/helpers/adapter_helpers.py @@ -226,7 +226,7 @@ def get_adapter( return MockAdServerAdapter( adapter_config, principal, dry_run, tenant_id=tenant_id, strategy_context=testing_context ) - elif selected_adapter == "google_ad_manager": + if selected_adapter == "google_ad_manager": # network_code is required for GoogleAdManager network_code = adapter_config.get("network_code") if not network_code or not isinstance(network_code, str): @@ -247,16 +247,15 @@ def get_adapter( targeting_config=targeting_config, naming_templates=naming_templates, ) - elif selected_adapter in {"triton", "triton_digital"}: + if selected_adapter in {"triton", "triton_digital"}: return TritonAdapter(adapter_config, principal, dry_run, tenant_id=tenant_id) - elif selected_adapter == "freewheel": + if selected_adapter == "freewheel": return FreeWheelAdapter(adapter_config, principal, dry_run, tenant_id=tenant_id) - elif selected_adapter == "broadstreet": + if selected_adapter == "broadstreet": return BroadstreetAdapter(adapter_config, principal, dry_run, tenant_id=tenant_id) - elif selected_adapter == "springserve": + if selected_adapter == "springserve": return SpringServeAdapter(adapter_config, principal, dry_run, tenant_id=tenant_id) - else: - # Default to mock for unsupported adapters - return MockAdServerAdapter( - adapter_config, principal, dry_run, tenant_id=tenant_id, strategy_context=testing_context - ) + # Default to mock for unsupported adapters + return MockAdServerAdapter( + adapter_config, principal, dry_run, tenant_id=tenant_id, strategy_context=testing_context + ) diff --git a/src/core/helpers/creative_helpers.py b/src/core/helpers/creative_helpers.py index 041b53e569..7de07760ca 100644 --- a/src/core/helpers/creative_helpers.py +++ b/src/core/helpers/creative_helpers.py @@ -535,12 +535,11 @@ def _detect_snippet_type(snippet: str) -> str: """Auto-detect snippet type from content for legacy support.""" if snippet.startswith(" Callable: if is_async: return self._wrap_async_tool(tool_func) - else: - return self._wrap_sync_tool(tool_func) + return self._wrap_sync_tool(tool_func) def _wrap_async_tool(self, tool_func: AsyncMCPTool) -> Callable: """Wrap an async MCP tool.""" @@ -205,12 +204,11 @@ def _create_tool_context(self, fastmcp_context: FastMCPContext, tool_name: str) # Determine if header is missing or just invalid if auth_header == "NOT_PRESENT": raise ValueError(f"Missing x-adcp-auth header. Apx-Incoming-Host: {apx_host}") - else: - raise ValueError( - f"Invalid x-adcp-auth token (not found in database). " - f"Token: {auth_header[:20]}..., " - f"Apx-Incoming-Host: {apx_host}" - ) + raise ValueError( + f"Invalid x-adcp-auth token (not found in database). " + f"Token: {auth_header[:20]}..., " + f"Apx-Incoming-Host: {apx_host}" + ) if not identity.tenant_id: raise ValueError( diff --git a/src/core/mcp_server_enhanced.py b/src/core/mcp_server_enhanced.py index bb8e0ef9f2..1303d7fe62 100644 --- a/src/core/mcp_server_enhanced.py +++ b/src/core/mcp_server_enhanced.py @@ -50,8 +50,7 @@ def decorator(f: Callable) -> FunctionTool: if func is None: return decorator - else: - return decorator(func) + return decorator(func) async def _handle_tool_call(self, tool_name: str, arguments: dict, context: FastMCPContext) -> Any: """Handle tool calls and inject context_id into responses. diff --git a/src/core/product_conversion.py b/src/core/product_conversion.py index 9b3f4edcb3..3abfcba2dd 100644 --- a/src/core/product_conversion.py +++ b/src/core/product_conversion.py @@ -161,17 +161,16 @@ def get_attr(obj, key): **common_fields, fixed_price=float(rate), ) - else: - return CpmPricingOption( - **_auction_pricing_fields( - pricing_model=pricing_model, - pricing_option_id=pricing_option_id, - common_fields=common_fields, - price_guidance=price_guidance, - ) + return CpmPricingOption( + **_auction_pricing_fields( + pricing_model=pricing_model, + pricing_option_id=pricing_option_id, + common_fields=common_fields, + price_guidance=price_guidance, ) + ) - elif pricing_model == "vcpm": + if pricing_model == "vcpm": if is_fixed: if not rate: raise ValueError(f"Fixed VCPM pricing option {pricing_option_id} requires rate") @@ -179,17 +178,16 @@ def get_attr(obj, key): **common_fields, fixed_price=float(rate), ) - else: - return VcpmPricingOption( - **_auction_pricing_fields( - pricing_model=pricing_model, - pricing_option_id=pricing_option_id, - common_fields=common_fields, - price_guidance=price_guidance, - ) + return VcpmPricingOption( + **_auction_pricing_fields( + pricing_model=pricing_model, + pricing_option_id=pricing_option_id, + common_fields=common_fields, + price_guidance=price_guidance, ) + ) - elif pricing_model == "cpc": + if pricing_model == "cpc": if is_fixed: if not rate: raise ValueError(f"Fixed CPC pricing option {pricing_option_id} requires rate") @@ -197,17 +195,16 @@ def get_attr(obj, key): **common_fields, fixed_price=float(rate), ) - else: - return CpcPricingOption( - **_auction_pricing_fields( - pricing_model=pricing_model, - pricing_option_id=pricing_option_id, - common_fields=common_fields, - price_guidance=price_guidance, - ) + return CpcPricingOption( + **_auction_pricing_fields( + pricing_model=pricing_model, + pricing_option_id=pricing_option_id, + common_fields=common_fields, + price_guidance=price_guidance, ) + ) - elif pricing_model == "cpcv": + if pricing_model == "cpcv": # CPCV (Cost Per Completed View) - typically fixed rate if not rate: raise ValueError(f"CPCV pricing option {pricing_option_id} requires rate") @@ -220,7 +217,7 @@ def get_attr(obj, key): result_fields["parameters"] = parameters return CpcvPricingOption(**result_fields) - elif pricing_model == "cpv": + if pricing_model == "cpv": # CPV (Cost Per View) - typically auction-based if not rate: raise ValueError(f"CPV pricing option {pricing_option_id} requires rate") @@ -234,7 +231,7 @@ def get_attr(obj, key): result_fields["parameters"] = parameters return CpvPricingOption(**result_fields) - elif pricing_model == "cpp": + if pricing_model == "cpp": # CPP (Cost Per Point) - requires demographic parameters if not rate: raise ValueError(f"CPP pricing option {pricing_option_id} requires rate") @@ -246,7 +243,7 @@ def get_attr(obj, key): parameters=parameters, ) - elif pricing_model == "flat_rate": + if pricing_model == "flat_rate": # Flat rate pricing - fixed cost regardless of delivery if not rate: raise ValueError(f"Flat rate pricing option {pricing_option_id} requires rate") @@ -262,10 +259,9 @@ def get_attr(obj, key): result_fields["parameters"] = parameters return FlatRatePricingOption(**result_fields) - else: - raise ValueError( - f"Unsupported pricing_model '{pricing_model}'. Supported models: cpm, vcpm, cpc, cpcv, cpv, cpp, flat_rate" - ) + raise ValueError( + f"Unsupported pricing_model '{pricing_model}'. Supported models: cpm, vcpm, cpc, cpcv, cpv, cpp, flat_rate" + ) def convert_product_model_to_schema(product_model, adapter_type: str | None = None) -> Product: diff --git a/src/core/property_list_resolver.py b/src/core/property_list_resolver.py index f89a3bdf3c..0f8df63175 100644 --- a/src/core/property_list_resolver.py +++ b/src/core/property_list_resolver.py @@ -68,8 +68,7 @@ async def resolve_property_list(ref: PropertyListReference) -> list[str]: if datetime.now(UTC) < expires_at: logger.debug("Cache hit for property list %s/%s", ref.agent_url, ref.list_id) return identifiers - else: - del _cache[cache_key] + del _cache[cache_key] # Build request url = agent_url_str.rstrip("/") + "/lists/" + ref.list_id diff --git a/src/core/schemas/_base.py b/src/core/schemas/_base.py index b1a3683a42..7676054b8b 100644 --- a/src/core/schemas/_base.py +++ b/src/core/schemas/_base.py @@ -328,8 +328,7 @@ def __str__(self) -> str: """Return human-readable summary message for protocol envelope.""" if self.errors: return f"Media buy creation encountered {len(self.errors)} error(s)." - else: - return "Media buy creation failed." + return "Media buy creation failed." class CreateMediaBuySubmitted(AdCPCreateMediaBuySubmitted): @@ -514,8 +513,7 @@ def __str__(self) -> str: """Return human-readable summary message for protocol envelope.""" if self.errors: return f"Media buy update encountered {len(self.errors)} error(s)." - else: - return "Media buy update failed." + return "Media buy update failed." # Union type for update_media_buy operation @@ -1173,13 +1171,12 @@ def extract_budget_amount(budget: "Budget | float | dict | None", default_curren """ if budget is None: return (0.0, default_currency) - elif isinstance(budget, dict): + if isinstance(budget, dict): return (budget.get("total", 0.0), budget.get("currency", default_currency)) - elif isinstance(budget, int | float): + if isinstance(budget, int | float): return (float(budget), default_currency) - else: - # Budget object with .total and .currency attributes - return (budget.total, budget.currency) + # Budget object with .total and .currency attributes + return (budget.total, budget.currency) # AdCP Compliance Models @@ -1189,7 +1186,7 @@ class Measurement(LibraryMeasurement): Extends library type - all fields inherited from AdCP spec. """ - pass # All fields inherited from library + # All fields inherited from library class AIReviewPolicy(SalesAgentBaseModel): @@ -2101,7 +2098,7 @@ class SignalFilters(LibrarySignalFilters): Extends library type - all fields inherited. """ - pass # All fields inherited from library + # All fields inherited from library # Re-export the library type; callers use .signal_spec, .filters, .max_results directly. @@ -2124,7 +2121,7 @@ def __str__(self) -> str: count = len(self.signals or []) if count == 0: return "No signals found matching your criteria." - elif count == 1: + if count == 1: return "Found 1 signal." return f"Found {count} signals." @@ -2353,10 +2350,9 @@ def __str__(self) -> str: count = len(self.publisher_domains) if count == 0: return "No authorized publisher domains found." - elif count == 1: + if count == 1: return "Found 1 authorized publisher domain." - else: - return f"Found {count} authorized publisher domains." + return f"Found {count} authorized publisher domains." # --- Get Media Buys Types --- diff --git a/src/core/schemas/creative.py b/src/core/schemas/creative.py index 1ad84c8119..ef3aee953f 100644 --- a/src/core/schemas/creative.py +++ b/src/core/schemas/creative.py @@ -673,10 +673,9 @@ def __str__(self) -> str: count = len(self.formats) if count == 0: return "No creative formats are currently supported." - elif count == 1: + if count == 1: return "Found 1 creative format." - else: - return f"Found {count} creative formats." + return f"Found {count} creative formats." class ListCreativesRequest(LibraryListCreativesRequest): @@ -716,7 +715,7 @@ class Pagination(LibraryResponsePagination): This is the appropriate type for list endpoints like list_creatives. """ - pass # Inherits all fields from library: cursor, has_more, total_count + # Inherits all fields from library: cursor, has_more, total_count class ListCreativesResponse(NestedModelSerializerMixin, LibraryListCreativesResponse): @@ -743,8 +742,7 @@ def __str__(self) -> str: total = self.query_summary.total_matching if count == total: return f"Found {count} creative{'s' if count != 1 else ''}." - else: - return f"Showing {count} of {total} creatives." + return f"Showing {count} of {total} creatives." class CheckCreativeStatusRequest(SalesAgentBaseModel): diff --git a/src/core/schemas/delivery.py b/src/core/schemas/delivery.py index 8523f880a4..db7a821ea5 100644 --- a/src/core/schemas/delivery.py +++ b/src/core/schemas/delivery.py @@ -37,7 +37,7 @@ class DeliveryMeasurement(LibraryDeliveryMeasurement): The buyer accepts the declared provider as the source of truth for the buy. """ - pass # All fields inherited from library + # All fields inherited from library class DeliveryType(str, Enum): @@ -238,7 +238,7 @@ class AggregatedTotals(LibraryAggregatedTotals): Extends library type - all fields inherited from AdCP spec. """ - pass # All fields inherited from library + # All fields inherited from library # --------------------------------------------------------------------------- @@ -288,7 +288,7 @@ def __str__(self) -> str: count = len(self.media_buy_deliveries) if count == 0: return "No delivery data found for the specified period." - elif count == 1: + if count == 1: return "Retrieved delivery data for 1 media buy." return f"Retrieved delivery data for {count} media buys." @@ -428,7 +428,7 @@ class DeliveryMetrics(LibraryDeliveryMetrics): frequency, viewability, quartile_data, etc. """ - pass # All fields inherited from library + # All fields inherited from library class CreativeDeliveryData(SalesAgentBaseModel): @@ -464,7 +464,7 @@ def __str__(self) -> str: count = len(self.creatives) if count == 0: return "No creative delivery data found for the specified period." - elif count == 1: + if count == 1: return "Retrieved delivery data for 1 creative." return f"Retrieved delivery data for {count} creatives." diff --git a/src/core/schemas/product.py b/src/core/schemas/product.py index 132a1b6766..cccf92c7b5 100644 --- a/src/core/schemas/product.py +++ b/src/core/schemas/product.py @@ -30,7 +30,7 @@ class ProductCard(LibraryProductCard): Standard card is 300x400px for marketplace display. """ - pass # All fields inherited from library + # All fields inherited from library class ProductCardDetailed(LibraryProductCardDetailed): @@ -40,7 +40,7 @@ class ProductCardDetailed(LibraryProductCardDetailed): Provides rich product presentation similar to media kit pages. """ - pass # All fields inherited from library + # All fields inherited from library class Placement(LibraryPlacement): diff --git a/src/core/signals_agent_registry.py b/src/core/signals_agent_registry.py index a6f9d228dc..5c92d9203b 100644 --- a/src/core/signals_agent_registry.py +++ b/src/core/signals_agent_registry.py @@ -73,7 +73,7 @@ class SignalsAgentRegistry: def __init__(self): """Initialize registry.""" - pass # No cache needed - adcp library handles connection pooling + # No cache needed - adcp library handles connection pooling def _get_tenant_agents(self, tenant_id: str) -> list[SignalsAgent]: """Get list of signals agents for a tenant. @@ -192,7 +192,7 @@ async def _get_signals_from_agent( result_signals.append(signal.model_dump(mode="json")) return result_signals - elif result.status == "submitted": + if result.status == "submitted": # Asynchronous completion - webhook registered total_duration = time.time() - start_time if result.submitted is None: @@ -204,8 +204,7 @@ async def _get_signals_from_agent( # For now, return empty list (webhook will deliver results later) return [] - else: - raise AdCPAdapterError(f"Unexpected result status from {agent.name}: {result.status}") + raise AdCPAdapterError(f"Unexpected result status from {agent.name}: {result.status}") except ADCPAuthenticationError as e: logger.error(f"Authentication failed for {agent.name}: {e.message}") diff --git a/src/core/strategy.py b/src/core/strategy.py index a33480741f..cd6b2df103 100644 --- a/src/core/strategy.py +++ b/src/core/strategy.py @@ -21,14 +21,10 @@ class StrategyError(Exception): """Base exception for strategy-related errors.""" - pass - class SimulationError(StrategyError): """Errors related to simulation control.""" - pass - class JumpEvent(str, Enum): """Predefined events for simulation time jumping.""" @@ -105,8 +101,7 @@ def _create_default_strategy(self, strategy_id: str) -> StrategyModel: if is_simulation: return self._create_simulation_strategy(strategy_id) - else: - return self._create_production_strategy(strategy_id) + return self._create_production_strategy(strategy_id) def _create_production_strategy(self, strategy_id: str) -> StrategyModel: """Create a production strategy.""" @@ -234,15 +229,14 @@ def control_simulation(self, strategy_id: str, action: str, parameters: dict[str if not target: raise SimulationError("jump_to requires either 'event' or 'target_date' parameter") return sim_context.jump_to_event(target) - elif action == "reset": + if action == "reset": return sim_context.reset() - elif action == "set_scenario": + if action == "set_scenario": scenario = parameters.get("scenario") if not isinstance(scenario, str): raise SimulationError("set_scenario requires 'scenario' parameter to be a string") return sim_context.set_scenario(scenario) - else: - raise SimulationError(f"Unknown simulation action: {action}") + raise SimulationError(f"Unknown simulation action: {action}") def _get_simulation_context(self, strategy_id: str, strategy: "StrategyContext") -> "SimulationContext": """Get or create simulation context.""" @@ -334,34 +328,33 @@ def jump_to_event(self, event: str) -> dict[str, Any]: if event.startswith("+"): # Relative time jump: "+1d", "+6h", etc. return self._advance_time(event[1:]) - elif event in [e.value for e in JumpEvent]: + if event in [e.value for e in JumpEvent]: # Jump to predefined event return self._trigger_event(event) - else: - # Try to parse as an absolute date (e.g., "2025-09-15") - try: - from datetime import datetime - - target_date = datetime.strptime(event, "%Y-%m-%d") - old_time = self.current_time - self.current_time = target_date - self.events_triggered.append( - { - "event": "time_jumped", - "old_time": old_time.isoformat(), - "new_time": self.current_time.isoformat(), - "target": event, - "triggered_at": datetime.now(UTC).isoformat(), - } - ) - self._save_state() - return { - "status": "ok", - "message": f"Jumped to {event}", - "current_time": self.current_time.isoformat(), + # Try to parse as an absolute date (e.g., "2025-09-15") + try: + from datetime import datetime + + target_date = datetime.strptime(event, "%Y-%m-%d") + old_time = self.current_time + self.current_time = target_date + self.events_triggered.append( + { + "event": "time_jumped", + "old_time": old_time.isoformat(), + "new_time": self.current_time.isoformat(), + "target": event, + "triggered_at": datetime.now(UTC).isoformat(), } - except ValueError as e: - raise SimulationError(f"Unknown jump event: {event}") from e + ) + self._save_state() + return { + "status": "ok", + "message": f"Jumped to {event}", + "current_time": self.current_time.isoformat(), + } + except ValueError as e: + raise SimulationError(f"Unknown jump event: {event}") from e def _advance_time(self, duration_str: str) -> dict[str, Any]: """Advance simulation time by duration.""" @@ -470,14 +463,13 @@ def _parse_duration(self, duration_str: str) -> timedelta: """Parse duration string into timedelta.""" if duration_str.endswith("d"): return timedelta(days=int(duration_str[:-1])) - elif duration_str.endswith("h"): + if duration_str.endswith("h"): return timedelta(hours=int(duration_str[:-1])) - elif duration_str.endswith("m"): + if duration_str.endswith("m"): return timedelta(minutes=int(duration_str[:-1])) - elif duration_str.endswith("s"): + if duration_str.endswith("s"): return timedelta(seconds=int(duration_str[:-1])) - else: - raise SimulationError(f"Invalid duration format: {duration_str}") + raise SimulationError(f"Invalid duration format: {duration_str}") def register_media_buy(self, media_buy_id: str, initial_state: dict[str, Any]): """Register a media buy in this simulation.""" diff --git a/src/core/tenant_status.py b/src/core/tenant_status.py index aa611c9354..bbdba631b9 100644 --- a/src/core/tenant_status.py +++ b/src/core/tenant_status.py @@ -67,19 +67,19 @@ def is_tenant_ad_server_configured(tenant_id: str) -> bool: ) return has_auth - elif adapter_type == "mock": + if adapter_type == "mock": # Mock adapter is NOT considered configured for production use # Users should configure a real ad server (GAM, etc.) return False - elif adapter_type in {"triton", "triton_digital"}: + if adapter_type in {"triton", "triton_digital"}: config = adapter_config.config_json or {} has_creds = bool(config.get("username") and config.get("password")) if not has_creds: logger.info(f"Tenant {tenant_id} Triton adapter missing publisher credentials") return has_creds - elif adapter_type == "freewheel": + if adapter_type == "freewheel": config = adapter_config.config_json or {} has_password_grant = bool(config.get("username") and config.get("password")) has_token = bool(config.get("api_token")) @@ -91,10 +91,9 @@ def is_tenant_ad_server_configured(tenant_id: str) -> bool: ) return has_creds - else: - # Unknown adapter type - consider it configured if it has a type - logger.warning(f"Unknown adapter type '{adapter_type}' for tenant {tenant_id}") - return True + # Unknown adapter type - consider it configured if it has a type + logger.warning(f"Unknown adapter type '{adapter_type}' for tenant {tenant_id}") + return True except Exception as e: logger.error(f"Error checking tenant {tenant_id} configuration: {e}", exc_info=True) diff --git a/src/core/testing_api.py b/src/core/testing_api.py index 7e23710737..60c94f621f 100644 --- a/src/core/testing_api.py +++ b/src/core/testing_api.py @@ -98,31 +98,30 @@ def handle_testing_control(req: TestingControlRequest, context: Context) -> Test data={"session_id": session_id, "created_at": session["created_at"].isoformat()}, ) - elif req.action == "cleanup_session": + if req.action == "cleanup_session": if not req.session_id: return TestingControlResponse(success=False, message="session_id required for cleanup") session_manager.cleanup_session(req.session_id) return TestingControlResponse(success=True, message=f"Session {req.session_id} cleaned up") - elif req.action == "list_sessions": + if req.action == "list_sessions": sessions = session_manager.list_sessions() return TestingControlResponse( success=True, message=f"Found {len(sessions)} active sessions", data={"sessions": sessions} ) - elif req.action == "get_capabilities": + if req.action == "get_capabilities": capabilities = get_testing_capabilities() return TestingControlResponse( success=True, message="Testing capabilities retrieved", data=capabilities.model_dump() ) - elif req.action == "inspect_context": + if req.action == "inspect_context": return TestingControlResponse( success=True, message="Current testing context", data=testing_ctx.model_dump() ) - else: - return TestingControlResponse(success=False, message=f"Unknown action: {req.action}") + return TestingControlResponse(success=False, message=f"Unknown action: {req.action}") except Exception as e: return TestingControlResponse(success=False, message=f"Error: {str(e)}") diff --git a/src/core/testing_hooks.py b/src/core/testing_hooks.py index 589c1b5f71..55b27c2f57 100644 --- a/src/core/testing_hooks.py +++ b/src/core/testing_hooks.py @@ -220,9 +220,7 @@ def get_next_event( if not current_event: if progress == 0.0: current_event = CampaignEvent.CAMPAIGN_CREATION - elif progress < 0.1: - current_event = CampaignEvent.CAMPAIGN_START - elif progress < 0.5: + elif progress < 0.1 or progress < 0.5: current_event = CampaignEvent.CAMPAIGN_START elif progress < 0.75: current_event = CampaignEvent.CAMPAIGN_MIDPOINT @@ -241,9 +239,9 @@ def get_next_event( # Return appropriate next event based on progress if progress < 0.5: return CampaignEvent.CAMPAIGN_MIDPOINT - elif progress < 0.75: + if progress < 0.75: return CampaignEvent.CAMPAIGN_75_PERCENT - elif progress < 1.0: + if progress < 1.0: return CampaignEvent.CAMPAIGN_COMPLETE return None diff --git a/src/core/tool_error_logging.py b/src/core/tool_error_logging.py index a0a48c8f01..7bf803cbf1 100644 --- a/src/core/tool_error_logging.py +++ b/src/core/tool_error_logging.py @@ -74,7 +74,7 @@ def extract_error_info(error: Exception) -> tuple[str, str, str | None]: if isinstance(error, AdCPError): return error.error_code, error.message, error.recovery - elif isinstance(error, ToolError): + if isinstance(error, ToolError): # ToolError may be constructed as ToolError("CODE", "message", "recovery") # or ToolError("CODE", "message") or ToolError("message") # Check if first arg looks like an error code (all caps, no spaces, reasonable length) @@ -90,12 +90,10 @@ def extract_error_info(error: Exception) -> tuple[str, str, str | None]: # Structured format: ToolError("CODE", "message") or ("CODE", "message", "recovery") recovery = str(error.args[2]) if len(error.args) > 2 else None return first_arg, str(error.args[1]), recovery - else: - # Single-arg format: ToolError("message") - return "TOOL_ERROR", str(error), None + # Single-arg format: ToolError("message") + return "TOOL_ERROR", str(error), None return "TOOL_ERROR", str(error), None - else: - return type(error).__name__, str(error), None + return type(error).__name__, str(error), None def _log_tool_error(tool_name: str, error: Exception, tenant_id: str | None, principal_id: str | None) -> None: @@ -158,7 +156,7 @@ def _translate_to_tool_error(error: Exception) -> NoReturn: if isinstance(error, ToolError): raise - elif isinstance(error, AdCPError): + if isinstance(error, AdCPError): # Include details as JSON 4th arg so the MCP round-trip preserves them. # The lowlevel server does str(exception) which produces a tuple string: # "('CODE', 'message', 'recovery', '{\"suggestion\": \"...\"}')" @@ -170,12 +168,11 @@ def _translate_to_tool_error(error: Exception) -> NoReturn: except (TypeError, ValueError): details_json = None raise ToolError(error.error_code, error.message, error.recovery, details_json) from error - elif isinstance(error, ValueError): + if isinstance(error, ValueError): raise ToolError("VALIDATION_ERROR", str(error)) from error - elif isinstance(error, PermissionError): + if isinstance(error, PermissionError): raise ToolError("AUTHORIZATION_ERROR", str(error)) from error - else: - raise + raise def with_error_logging(tool_func: Callable) -> Callable: @@ -224,29 +221,28 @@ async def async_wrapper(*args, **kwargs) -> Any: _translate_to_tool_error(e) return async_wrapper - else: - @functools.wraps(tool_func) - def sync_wrapper(*args, **kwargs) -> Any: - try: - return tool_func(*args, **kwargs) - except Exception as e: - # Extract context from args/kwargs - context = None - for arg in args: - if isinstance(arg, FastMCPContext) or hasattr(arg, "tenant_id"): - context = arg - break - for v in kwargs.values(): - if isinstance(v, FastMCPContext) or hasattr(v, "tenant_id"): - context = v - break - - # Extract tenant/principal and log error - tenant_id, principal_id = _extract_tenant_and_principal(context) if context else (None, None) - _log_tool_error(tool_func.__name__, e, tenant_id, principal_id) - - # Translate typed exceptions to ToolError at the MCP boundary - _translate_to_tool_error(e) - - return sync_wrapper + @functools.wraps(tool_func) + def sync_wrapper(*args, **kwargs) -> Any: + try: + return tool_func(*args, **kwargs) + except Exception as e: + # Extract context from args/kwargs + context = None + for arg in args: + if isinstance(arg, FastMCPContext) or hasattr(arg, "tenant_id"): + context = arg + break + for v in kwargs.values(): + if isinstance(v, FastMCPContext) or hasattr(v, "tenant_id"): + context = v + break + + # Extract tenant/principal and log error + tenant_id, principal_id = _extract_tenant_and_principal(context) if context else (None, None) + _log_tool_error(tool_func.__name__, e, tenant_id, principal_id) + + # Translate typed exceptions to ToolError at the MCP boundary + _translate_to_tool_error(e) + + return sync_wrapper diff --git a/src/core/tools/creatives/_assignments.py b/src/core/tools/creatives/_assignments.py index 54978795c8..2b5c2ff245 100644 --- a/src/core/tools/creatives/_assignments.py +++ b/src/core/tools/creatives/_assignments.py @@ -92,9 +92,8 @@ def _process_assignments( # Skip if in lenient mode, error if strict if validation_mode == "strict": raise AdCPNotFoundError(error_msg, recovery="correctable") - else: - logger.warning(f"Package not found during assignment: {package_id}, skipping") - continue + logger.warning(f"Package not found during assignment: {package_id}, skipping") + continue # Validate creative format against package product formats db_creative_result = assignment_repo.get_creative_by_id(creative_id) @@ -168,9 +167,8 @@ def _process_assignments( if validation_mode == "strict": raise AdCPValidationError(error_msg) - else: - logger.warning(f"Creative format mismatch during assignment, skipping: {error_msg}") - continue + logger.warning(f"Creative format mismatch during assignment, skipping: {error_msg}") + continue # Check if assignment already exists (idempotent operation) # actual_package_id is always set when media_buy_id is set (guard above) diff --git a/src/core/tools/creatives/_validation.py b/src/core/tools/creatives/_validation.py index 6f99a4d56a..e65b080928 100644 --- a/src/core/tools/creatives/_validation.py +++ b/src/core/tools/creatives/_validation.py @@ -145,7 +145,7 @@ def _validate_creative_input( f"is unreachable or returned an error. Please verify the agent URL is correct " f"and the agent is running. Error: {str(validation_error)}" ) - elif not format_spec: + if not format_spec: # Format not found (agent is reachable but format doesn't exist) raise ValueError( f"Unknown format '{format_id}' from agent {agent_url}. " diff --git a/src/core/tools/media_buy_update.py b/src/core/tools/media_buy_update.py index e1340cb803..29ab5e4572 100644 --- a/src/core/tools/media_buy_update.py +++ b/src/core/tools/media_buy_update.py @@ -1176,53 +1176,52 @@ def _update_media_buy_impl( error_message=result.errors[0].message if result.errors else "Pause/resume failed", ) return error_response - else: - # UpdateMediaBuySuccess extends adcp v1.2.1 with internal fields - # Use getattr to safely access discriminated union fields - media_buy_id = getattr(result, "media_buy_id", req.media_buy_id or "") - affected_pkgs = getattr(result, "affected_packages", []) - - # Echo the resulting media-buy lifecycle status. Resume restores - # the pre-pause blocker/date state instead of blindly reporting - # active, so the response and get_media_buys readback agree. - resulting_media_buy_status = _persist_media_buy_pause_state( - uow.media_buys, - current_mb, - req.media_buy_id, - req.paused, - ) - response_revision = _increment_revision_for_response( - uow.media_buys, - req.media_buy_id, - current_revision, - ) - success_response = UpdateMediaBuySuccess( - media_buy_id=media_buy_id, - media_buy_status=resulting_media_buy_status, - affected_packages=affected_pkgs, - revision=response_revision, - context=req.context, - ) - # Log successful update_media_buy (pause/resume) - audit_logger = get_audit_logger("AdCP", tenant["tenant_id"]) - audit_logger.log_operation( - operation="update_media_buy", - principal_name=principal_id or "anonymous", - principal_id=principal_id or "anonymous", - adapter_id="mcp_server", - success=True, - details={ - "media_buy_id": req.media_buy_id, - "action": action, - "affected_packages_count": len(affected_pkgs), - }, - ) - ctx_manager.update_workflow_step( - step.step_id, - status="completed", - response_data=serialize_for_workflow_step(success_response), - ) - return success_response + # UpdateMediaBuySuccess extends adcp v1.2.1 with internal fields + # Use getattr to safely access discriminated union fields + media_buy_id = getattr(result, "media_buy_id", req.media_buy_id or "") + affected_pkgs = getattr(result, "affected_packages", []) + + # Echo the resulting media-buy lifecycle status. Resume restores + # the pre-pause blocker/date state instead of blindly reporting + # active, so the response and get_media_buys readback agree. + resulting_media_buy_status = _persist_media_buy_pause_state( + uow.media_buys, + current_mb, + req.media_buy_id, + req.paused, + ) + response_revision = _increment_revision_for_response( + uow.media_buys, + req.media_buy_id, + current_revision, + ) + success_response = UpdateMediaBuySuccess( + media_buy_id=media_buy_id, + media_buy_status=resulting_media_buy_status, + affected_packages=affected_pkgs, + revision=response_revision, + context=req.context, + ) + # Log successful update_media_buy (pause/resume) + audit_logger = get_audit_logger("AdCP", tenant["tenant_id"]) + audit_logger.log_operation( + operation="update_media_buy", + principal_name=principal_id or "anonymous", + principal_id=principal_id or "anonymous", + adapter_id="mcp_server", + success=True, + details={ + "media_buy_id": req.media_buy_id, + "action": action, + "affected_packages_count": len(affected_pkgs), + }, + ) + ctx_manager.update_workflow_step( + step.step_id, + status="completed", + response_data=serialize_for_workflow_step(success_response), + ) + return success_response # Handle package-level updates # (Package existence pre-validated above for issue #251 — every diff --git a/src/core/tools/products.py b/src/core/tools/products.py index 8e7c297fe1..8ab2ab5ee3 100644 --- a/src/core/tools/products.py +++ b/src/core/tools/products.py @@ -105,7 +105,7 @@ def extract_product_property_ids( if inner.selection_type == "all": # Product covers ALL properties for this domain return None - elif inner.selection_type == "by_id": + if inner.selection_type == "by_id": for pid in inner.property_ids: property_ids.add(pid.root) # by_tag: we don't resolve tags to IDs here; tags are excluded from matching @@ -267,7 +267,7 @@ async def _get_products_impl( # Enforce policy-based validation if brand_manifest_policy == "require_brand" and not offering: raise AdCPAuthorizationError("Brand manifest required by tenant policy", recovery="correctable") - elif brand_manifest_policy == "require_auth" and not principal_id: + if brand_manifest_policy == "require_auth" and not principal_id: raise AdCPAuthenticationError("Authentication required by tenant policy") # public policy allows all requests (no brand_manifest or auth required) diff --git a/src/core/tracing.py b/src/core/tracing.py index 71fa75d6a4..5cff9b24ff 100644 --- a/src/core/tracing.py +++ b/src/core/tracing.py @@ -58,24 +58,23 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: raise return async_wrapper - else: - @functools.wraps(func) - def sync_wrapper(*args: Any, **kwargs: Any) -> Any: - if not is_tracing_enabled(): - return func(*args, **kwargs) + @functools.wraps(func) + def sync_wrapper(*args: Any, **kwargs: Any) -> Any: + if not is_tracing_enabled(): + return func(*args, **kwargs) - tracer = get_tracer(_TRACER_NAME) - with tracer.start_as_current_span(name) as span: - _set_identity_attribute(span, args, kwargs, identity_arg_index) - try: - return func(*args, **kwargs) - except Exception as exc: - span.record_exception(exc) - span.set_status(Status(StatusCode.ERROR, str(exc))) - raise + tracer = get_tracer(_TRACER_NAME) + with tracer.start_as_current_span(name) as span: + _set_identity_attribute(span, args, kwargs, identity_arg_index) + try: + return func(*args, **kwargs) + except Exception as exc: + span.record_exception(exc) + span.set_status(Status(StatusCode.ERROR, str(exc))) + raise - return sync_wrapper + return sync_wrapper def _identity_positional_index(func: Callable) -> int | None: diff --git a/src/core/utils/mcp_client.py b/src/core/utils/mcp_client.py index 83aba4c425..bc978e3255 100644 --- a/src/core/utils/mcp_client.py +++ b/src/core/utils/mcp_client.py @@ -36,14 +36,10 @@ class MCPConnectionError(Exception): """Raised when MCP client connection fails after all retries.""" - pass - class MCPCompatibilityError(Exception): """Raised when MCP SDK version compatibility issue detected.""" - pass - def _build_auth_headers(auth: dict[str, Any] | None, auth_header: str | None = None) -> dict[str, str]: """Build authentication headers from auth config. diff --git a/src/core/utils/naming.py b/src/core/utils/naming.py index f4e918b9e8..31a3ef995a 100644 --- a/src/core/utils/naming.py +++ b/src/core/utils/naming.py @@ -31,10 +31,9 @@ def format_date_range(start_time: datetime, end_time: datetime) -> str: """ if start_time.year != end_time.year: return f"{start_time.strftime('%b %d, %Y')} - {end_time.strftime('%b %d, %Y')}" - elif start_time.month != end_time.month: + if start_time.month != end_time.month: return f"{start_time.strftime('%b %d')} - {end_time.strftime('%b %d, %Y')}" - else: - return f"{start_time.strftime('%b %d')}-{end_time.strftime('%d, %Y')}" + return f"{start_time.strftime('%b %d')}-{end_time.strftime('%d, %Y')}" def format_month_year(start_time: datetime) -> str: @@ -53,7 +52,7 @@ def _extract_brand_name(request) -> str | None: brand = request.brand if hasattr(brand, "domain"): return brand.domain - elif isinstance(brand, dict): + if isinstance(brand, dict): return brand.get("domain") return None diff --git a/src/landing/landing_page.py b/src/landing/landing_page.py index 194bbae1d3..693412c28e 100644 --- a/src/landing/landing_page.py +++ b/src/landing/landing_page.py @@ -72,7 +72,7 @@ def _extract_tenant_subdomain(tenant: dict, virtual_host: str | None = None) -> subdomain = extract_subdomain_from_host(virtual_host) if subdomain: return subdomain - elif "." in virtual_host: + if "." in virtual_host: # Generic virtual host, use first part return virtual_host.split(".")[0] diff --git a/src/services/activity_feed.py b/src/services/activity_feed.py index 058b7da4b2..dfda44d66a 100644 --- a/src/services/activity_feed.py +++ b/src/services/activity_feed.py @@ -194,12 +194,11 @@ def _get_relative_time(self, timestamp: str) -> str: if delta.days > 0: return f"{delta.days}d ago" - elif delta.seconds > 3600: + if delta.seconds > 3600: return f"{delta.seconds // 3600}h ago" - elif delta.seconds > 60: + if delta.seconds > 60: return f"{delta.seconds // 60}m ago" - else: - return "Just now" + return "Just now" except Exception: logger.debug("Failed to format time_ago", exc_info=True) return "Unknown" diff --git a/src/services/ai/factory.py b/src/services/ai/factory.py index 7380f32c40..aa0d54c28f 100644 --- a/src/services/ai/factory.py +++ b/src/services/ai/factory.py @@ -161,7 +161,7 @@ def _create_provider_model(self, provider: str, model_name: str, api_key: str | # is now the default ``"google"`` provider (#521 dep bump). return GoogleModel(model_name, provider="google") - elif provider == "anthropic": + if provider == "anthropic": from pydantic_ai.models.anthropic import AnthropicModel from pydantic_ai.providers.anthropic import AnthropicProvider @@ -169,7 +169,7 @@ def _create_provider_model(self, provider: str, model_name: str, api_key: str | return AnthropicModel(model_name, provider=AnthropicProvider(api_key=api_key)) return AnthropicModel(model_name, provider="anthropic") - elif provider == "openai": + if provider == "openai": from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.providers.openai import OpenAIProvider @@ -177,7 +177,7 @@ def _create_provider_model(self, provider: str, model_name: str, api_key: str | return OpenAIChatModel(model_name, provider=OpenAIProvider(api_key=api_key)) return OpenAIChatModel(model_name, provider="openai") - elif provider == "groq": + if provider == "groq": from pydantic_ai.models.groq import GroqModel from pydantic_ai.providers.groq import GroqProvider @@ -185,7 +185,7 @@ def _create_provider_model(self, provider: str, model_name: str, api_key: str | return GroqModel(model_name, provider=GroqProvider(api_key=api_key)) return GroqModel(model_name, provider="groq") - elif provider == "mistral": + if provider == "mistral": from pydantic_ai.models.mistral import MistralModel from pydantic_ai.providers.mistral import MistralProvider @@ -193,7 +193,7 @@ def _create_provider_model(self, provider: str, model_name: str, api_key: str | return MistralModel(model_name, provider=MistralProvider(api_key=api_key)) return MistralModel(model_name, provider="mistral") - elif provider == "cohere": + if provider == "cohere": from pydantic_ai.models.cohere import CohereModel from pydantic_ai.providers.cohere import CohereProvider @@ -201,15 +201,14 @@ def _create_provider_model(self, provider: str, model_name: str, api_key: str | return CohereModel(model_name, provider=CohereProvider(api_key=api_key)) return CohereModel(model_name, provider="cohere") - else: - # Fallback: use model string and let Pydantic AI resolve it - # This handles gateway providers and any new providers - model_string = build_model_string(provider, model_name) - logger.warning( - f"Provider '{provider}' not explicitly supported, " - f"using model string '{model_string}' (API key must be in env var)" - ) - return model_string + # Fallback: use model string and let Pydantic AI resolve it + # This handles gateway providers and any new providers + model_string = build_model_string(provider, model_name) + logger.warning( + f"Provider '{provider}' not explicitly supported, " + f"using model string '{model_string}' (API key must be in env var)" + ) + return model_string def is_ai_enabled( self, diff --git a/src/services/background_approval_service.py b/src/services/background_approval_service.py index de8bbb0c6a..3d90a28f00 100644 --- a/src/services/background_approval_service.py +++ b/src/services/background_approval_service.py @@ -153,12 +153,11 @@ def _run_approval_polling_thread( # Send webhook notification _send_approval_webhook(tenant_id, order_id, workflow_step_id, "completed") break - else: - # Still not ready - continue polling - logger.info( - f"[{workflow_step_id}] Order {order_id} forecasting not ready yet, " - f"will retry in {polling_interval_seconds}s" - ) + # Still not ready - continue polling + logger.info( + f"[{workflow_step_id}] Order {order_id} forecasting not ready yet, " + f"will retry in {polling_interval_seconds}s" + ) except Exception as e: logger.warning(f"[{workflow_step_id}] Approval attempt {attempt} failed: {e}") diff --git a/src/services/default_products.py b/src/services/default_products.py index 31b4e9303c..d21057506e 100644 --- a/src/services/default_products.py +++ b/src/services/default_products.py @@ -6,11 +6,14 @@ """ import json +import logging from datetime import UTC, datetime from typing import Any from src.core.canonical_formats import CANONICAL_DISPLAY_FORMAT_IDS, canonical_format_ref +logger = logging.getLogger(__name__) + def _display_format_refs(*sizes: tuple[int, int]) -> list[dict[str, Any]]: return [ @@ -218,7 +221,7 @@ def create_default_products_for_tenant(conn, tenant_id: str, industry: str = Non created_products.append(product_template["product_id"]) except Exception as e: - print(f"Warning: Failed to create default product {product_template['product_id']}: {e}") + logger.warning(f"Failed to create default product {product_template['product_id']}: {e}") continue conn.commit() diff --git a/src/services/dynamic_products.py b/src/services/dynamic_products.py index 2deb67346d..5b26665a50 100644 --- a/src/services/dynamic_products.py +++ b/src/services/dynamic_products.py @@ -390,7 +390,7 @@ def customize_name( # Fallback: use activation key if activation_key.get("type") == "key_value": return f"{template_name} - {activation_key['key']}={activation_key['value']}" - elif activation_key.get("type") == "segment_id": + if activation_key.get("type") == "segment_id": return f"{template_name} - Segment {activation_key['segment_id']}" return template_name diff --git a/src/services/gam_inventory_service.py b/src/services/gam_inventory_service.py index 37547612c2..bb42d5a501 100644 --- a/src/services/gam_inventory_service.py +++ b/src/services/gam_inventory_service.py @@ -678,7 +678,7 @@ def _convert_item_to_db_format(self, tenant_id: str, inventory_type: str, item, }, "last_synced": sync_time, } - elif inventory_type == "placement": + if inventory_type == "placement": return { "tenant_id": tenant_id, "inventory_type": "placement", @@ -695,7 +695,7 @@ def _convert_item_to_db_format(self, tenant_id: str, inventory_type: str, item, }, "last_synced": sync_time, } - elif inventory_type == "label": + if inventory_type == "label": return { "tenant_id": tenant_id, "inventory_type": "label", @@ -710,7 +710,7 @@ def _convert_item_to_db_format(self, tenant_id: str, inventory_type: str, item, }, "last_synced": sync_time, } - elif inventory_type == "audience_segment": + if inventory_type == "audience_segment": return { "tenant_id": tenant_id, "inventory_type": "audience_segment", @@ -728,8 +728,7 @@ def _convert_item_to_db_format(self, tenant_id: str, inventory_type: str, item, }, "last_synced": sync_time, } - else: - raise ValueError(f"Unknown inventory type: {inventory_type}") + raise ValueError(f"Unknown inventory type: {inventory_type}") def _flush_batch(self, to_insert: list, to_update: list): """Flush a batch of inserts and updates to database with timeout and connection recovery. @@ -1658,8 +1657,7 @@ def update_product_inventory(tenant_id, product_id): if success: return jsonify({"status": "success"}) - else: - return jsonify({"error": "Update failed"}), 400 + return jsonify({"error": "Update failed"}), 400 except Exception as e: logger.error(f"Failed to update product inventory: {e}", exc_info=True) diff --git a/src/services/gam_orders_service.py b/src/services/gam_orders_service.py index 749ad44669..f25f920bb5 100644 --- a/src/services/gam_orders_service.py +++ b/src/services/gam_orders_service.py @@ -297,9 +297,12 @@ def get_orders(self, tenant_id: str, filters: dict[str, Any] | None = None) -> l order_dict = self._order_to_dict(order) if filters and "has_line_items" in filters: filter_value = filters["has_line_items"] - if filter_value == "true" and not order_dict["has_line_items"]: - continue - elif filter_value == "false" and order_dict["has_line_items"]: + if ( + filter_value == "true" + and not order_dict["has_line_items"] + or filter_value == "false" + and order_dict["has_line_items"] + ): continue result.append(order_dict) @@ -448,15 +451,15 @@ def _calculate_delivery_status(self, line_items: list[GAMLineItem]) -> str: # Priority order: check for active delivery first if "DELIVERING" in statuses: return "DELIVERING" - elif "READY" in statuses: + if "READY" in statuses: return "READY" - elif all(s == "COMPLETED" for s in statuses): + if all(s == "COMPLETED" for s in statuses): return "COMPLETED" - elif all(s == "PAUSED" for s in statuses): + if all(s == "PAUSED" for s in statuses): return "PAUSED" - elif all(s == "DRAFT" for s in statuses): + if all(s == "DRAFT" for s in statuses): return "DRAFT" - elif "APPROVED" in statuses: + if "APPROVED" in statuses: # APPROVED could mean various things, check dates now = datetime.now(UTC) for li in line_items: @@ -481,17 +484,16 @@ def _calculate_delivery_status(self, line_items: list[GAMLineItem]) -> str: if start_dt <= now <= end_dt: return "DELIVERING" - elif now < start_dt: + if now < start_dt: return "READY" - elif now > end_dt: + if now > end_dt: return "COMPLETED" return "APPROVED" - else: - # Return most common status - from collections import Counter + # Return most common status + from collections import Counter - status_counts = Counter(statuses) - return status_counts.most_common(1)[0][0] if status_counts else "UNKNOWN" + status_counts = Counter(statuses) + return status_counts.most_common(1)[0][0] if status_counts else "UNKNOWN" def _calculate_delivery_metrics(self, line_items: list[GAMLineItem]) -> dict[str, Any]: """ diff --git a/src/services/gcp_service_account_service.py b/src/services/gcp_service_account_service.py index 97c2c2e903..22d952ba59 100644 --- a/src/services/gcp_service_account_service.py +++ b/src/services/gcp_service_account_service.py @@ -300,10 +300,9 @@ def _get_service_account_if_exists(self, service_account_email: str) -> types.Se if "404" in str(e) or "NOT_FOUND" in str(e): logger.info(f"Service account {service_account_email} does not exist yet") return None - else: - # Some other error - log it but don't fail - logger.warning(f"Error checking if service account exists: {e}") - return None + # Some other error - log it but don't fail + logger.warning(f"Error checking if service account exists: {e}") + return None def _verify_service_account_exists(self, service_account_email: str) -> bool: """Verify that a service account exists in GCP. diff --git a/src/services/media_buy_status_scheduler.py b/src/services/media_buy_status_scheduler.py index 30fc5c10ec..52210ae35d 100644 --- a/src/services/media_buy_status_scheduler.py +++ b/src/services/media_buy_status_scheduler.py @@ -159,9 +159,8 @@ def _compute_new_status(self, media_buy: MediaBuy, now: datetime, session) -> st return "active" # Creatives not approved yet - stay pending return None - else: - # scheduled -> active (no creative check needed, already validated) - return "active" + # scheduled -> active (no creative check needed, already validated) + return "active" return None diff --git a/src/services/policy_check_service.py b/src/services/policy_check_service.py index bf4d3c0eb9..fdb75ad9d0 100644 --- a/src/services/policy_check_service.py +++ b/src/services/policy_check_service.py @@ -144,11 +144,10 @@ async def check_brief_compliance( restrictions=analysis.restrictions, warnings=analysis.warnings, ) - else: - # Fallback if no AI is available - allow with warning - return PolicyCheckResult( - status=PolicyStatus.ALLOWED, warnings=["Policy check unavailable - AI service not configured"] - ) + # Fallback if no AI is available - allow with warning + return PolicyCheckResult( + status=PolicyStatus.ALLOWED, warnings=["Policy check unavailable - AI service not configured"] + ) def _check_basic_rules(self, text: str) -> PolicyCheckResult: """Apply basic policy rules (deprecated - kept for compatibility). diff --git a/src/services/property_discovery_service.py b/src/services/property_discovery_service.py index ac06550874..b787cd1d08 100644 --- a/src/services/property_discovery_service.py +++ b/src/services/property_discovery_service.py @@ -193,9 +193,7 @@ def _filter_properties_by_domain( for prop in properties: identifiers = prop.get("identifiers", []) domain_identifiers = [ident.get("value", "") for ident in identifiers if ident.get("type") == "domain"] - if not domain_identifiers: - filtered.append(prop) - elif _domains_match(domain, domain_identifiers): + if not domain_identifiers or _domains_match(domain, domain_identifiers): filtered.append(prop) else: logger.debug( @@ -425,23 +423,22 @@ def _create_or_update_property( existing.updated_at = datetime.now(UTC) logger.debug(f"Updated property: {property_id}") return False - else: - new_property = AuthorizedProperty( - tenant_id=tenant_id, - property_id=property_id, - name=property_name, - property_type=property_type, - publisher_domain=publisher_domain, - identifiers=identifiers, - tags=property_tags, - verification_status="verified", - verification_checked_at=datetime.now(UTC), - created_at=datetime.now(UTC), - updated_at=datetime.now(UTC), - ) - session.add(new_property) - logger.debug(f"Created property: {property_id}") - return True + new_property = AuthorizedProperty( + tenant_id=tenant_id, + property_id=property_id, + name=property_name, + property_type=property_type, + publisher_domain=publisher_domain, + identifiers=identifiers, + tags=property_tags, + verification_status="verified", + verification_checked_at=datetime.now(UTC), + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + session.add(new_property) + logger.debug(f"Created property: {property_id}") + return True def _create_or_update_tag( self, session: Any, tenant_id: str, tag_id: str, existing_tags: dict[str, PropertyTag] diff --git a/src/services/property_verification_service.py b/src/services/property_verification_service.py index 66df346000..a4098d4b20 100644 --- a/src/services/property_verification_service.py +++ b/src/services/property_verification_service.py @@ -115,11 +115,10 @@ async def _verify_property_async(self, tenant_id: str, property_id: str, agent_u logger.info("✅ Agent verification successful!") self._update_verification_status(session, property_obj, "verified", None) return True, None - else: - error_msg = f"Agent {agent_url} not authorized for this property" - logger.error(f"❌ {error_msg}") - self._update_verification_status(session, property_obj, "failed", error_msg) - return False, error_msg + error_msg = f"Agent {agent_url} not authorized for this property" + logger.error(f"❌ {error_msg}") + self._update_verification_status(session, property_obj, "failed", error_msg) + return False, error_msg except Exception as e: logger.error(f"Error verifying property {property_id}: {e}") diff --git a/src/services/webhook_verification.py b/src/services/webhook_verification.py index 14178c7153..4459ba0d06 100644 --- a/src/services/webhook_verification.py +++ b/src/services/webhook_verification.py @@ -14,8 +14,6 @@ class WebhookVerificationError(Exception): """Raised when webhook verification fails.""" - pass - class WebhookVerifier: """Verifies AdCP webhook signatures and timestamps.""" From 5234af25d8db45e6cbdbd4d9436f1598affaee54 Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 16 Jul 2026 19:27:12 +0600 Subject: [PATCH 05/17] fix: make date defaults timezone-deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three date.today() call sites (GAM forecast window, GAM order projection, update-media-buy default) depended on the server's local timezone; use datetime.now(UTC).date() so behavior is identical across deployments. In strategy time-jump handling, current_time is initialized timezone-aware but the jump assigned a naive strptime result, so a later aware/naive comparison could raise TypeError — the parsed target date is now UTC-aware. Co-Authored-By: Claude Fable 5 --- src/adapters/gam/managers/forecast.py | 2 +- src/core/strategy.py | 2 +- src/core/tools/_gam_projection.py | 4 ++-- src/core/tools/media_buy_update.py | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/adapters/gam/managers/forecast.py b/src/adapters/gam/managers/forecast.py index 87ac6cf1f1..fb475e8f0c 100644 --- a/src/adapters/gam/managers/forecast.py +++ b/src/adapters/gam/managers/forecast.py @@ -155,7 +155,7 @@ def get_for_product( ``ForecastResult.forecast == None``. """ fetched_at = datetime.now(UTC).isoformat() - start_d = date.today() + timedelta(days=1) + start_d = datetime.now(UTC).date() + timedelta(days=1) end_d = start_d + timedelta(days=days) window_start = start_d.isoformat() window_end = end_d.isoformat() diff --git a/src/core/strategy.py b/src/core/strategy.py index cd6b2df103..37fb335cf6 100644 --- a/src/core/strategy.py +++ b/src/core/strategy.py @@ -335,7 +335,7 @@ def jump_to_event(self, event: str) -> dict[str, Any]: try: from datetime import datetime - target_date = datetime.strptime(event, "%Y-%m-%d") + target_date = datetime.strptime(event, "%Y-%m-%d").replace(tzinfo=UTC) old_time = self.current_time self.current_time = target_date self.events_triggered.append( diff --git a/src/core/tools/_gam_projection.py b/src/core/tools/_gam_projection.py index dae8cd03e4..142973791d 100644 --- a/src/core/tools/_gam_projection.py +++ b/src/core/tools/_gam_projection.py @@ -14,7 +14,7 @@ from __future__ import annotations from collections.abc import Iterable -from datetime import date, datetime +from datetime import UTC, date, datetime from decimal import Decimal from adcp.types import MediaBuyStatus @@ -285,7 +285,7 @@ def materialize_projected_buy( (advertiser.name if advertiser else None) or order.advertiser_name or order.advertiser_id or "Unknown" ) - today = date.today() + today = datetime.now(UTC).date() mb_repo = MediaBuyRepository(session, tenant_id) try: # Wrap the insert in a SAVEPOINT so a unique-index / PK collision diff --git a/src/core/tools/media_buy_update.py b/src/core/tools/media_buy_update.py index 29ab5e4572..1263e0ffcf 100644 --- a/src/core/tools/media_buy_update.py +++ b/src/core/tools/media_buy_update.py @@ -10,7 +10,7 @@ import logging import os -from datetime import UTC, date, datetime, timedelta +from datetime import UTC, datetime, timedelta from decimal import Decimal from typing import Any @@ -834,7 +834,7 @@ def _update_media_buy_impl( return response_data adapter = get_adapter(principal, dry_run=testing_ctx.dry_run, testing_context=testing_ctx, tenant=tenant) - today = req.today or date.today() + today = req.today or datetime.now(UTC).date() # Dry-run mode: Return simulated response without any database writes # Validation has passed (principal verified, media buy exists), so we return what WOULD be updated From 70f8cacc624077c12b911821cc43fc2be7230f49 Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 16 Jul 2026 19:27:12 +0600 Subject: [PATCH 06/17] fix: pass a concrete Session to GAMOrdersService in sync API The four sync endpoints passed the LazyScopedSession proxy where GAMOrdersService is annotated to take a sqlalchemy Session; calling the proxy returns the underlying thread-local Session (the same object the surrounding code commits through), which satisfies the annotation without behavior change. Clears the 4 mypy errors that had been masked by the incremental cache. Co-Authored-By: Claude Fable 5 --- src/admin/sync_api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/admin/sync_api.py b/src/admin/sync_api.py index daad73da37..7d4809b911 100644 --- a/src/admin/sync_api.py +++ b/src/admin/sync_api.py @@ -519,7 +519,7 @@ def sync_tenant_orders(tenant_id: str) -> tuple[Response, int]: ) # Perform sync - service = GAMOrdersService(db_session) + service = GAMOrdersService(db_session()) summary = service.sync_tenant_orders(tenant_id, adapter.client) # Update sync job with results @@ -596,7 +596,7 @@ def get_tenant_orders(tenant_id: str) -> tuple[Response, int]: return jsonify({"error": 'has_line_items must be "true" or "false"'}), 400 filters["has_line_items"] = has_line_items - service = GAMOrdersService(db_session) + service = GAMOrdersService(db_session()) orders = service.get_orders(tenant_id, filters) return jsonify({"total": len(orders), "orders": orders}), 200 @@ -623,7 +623,7 @@ def get_order_details(tenant_id: str, order_id: str) -> tuple[Response, int]: from src.services.gam_orders_service import GAMOrdersService - service = GAMOrdersService(db_session) + service = GAMOrdersService(db_session()) order_details = service.get_order_details(tenant_id, order_id) if not order_details: @@ -658,7 +658,7 @@ def get_tenant_line_items(tenant_id: str) -> tuple[Response, int]: order_id = request.args.get("order_id") - service = GAMOrdersService(db_session) + service = GAMOrdersService(db_session()) line_items = service.get_line_items(tenant_id, order_id, filters) return jsonify({"total": len(line_items), "line_items": line_items}), 200 From f300cc2cdec9130751df4d57436041c87c28da60 Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 16 Jul 2026 19:28:25 +0600 Subject: [PATCH 07/17] test: repair collection errors left by the 2.0 refactor and SDK bumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9 of the 20 unit test files that failed collection (all pre-existing: the same failures reproduce at the branch base commit in a clean worktree) are repaired with verified equivalents; assertions are unchanged: - src.app was deleted in "Sales Agent 2.0" (7fbd30ea8): the four REST/transport files now build the app via core.main.build_app(). NOTE: build_app() fail-fasts on the DB health check, so these four still need a DB-less bootstrap mode or a move to tests/integration. - adcp 6.4: FormatId moved to adcp.types; Assets classes renumbered (Assets5->Assets25 video, Assets9->Assets29 html, Assets18->Assets39 group, Assets19->Assets41, Assets22->Assets44) — mappings verified against the installed package. - MediaBuyStatus.pending_activation no longer exists; the faithful wire-enum substitute for "accepted, awaiting start" is pending_start. - test_delivery.py: dropped imports of the deleted flat-param MCP/A2A wrappers; the two wrapper-only tests are removed with a pointer to the delegate/harness transport coverage; the 11 _impl-based test classes are unchanged. - test_delivery_date_window.py: kept the _normalize_reporting_window half; removed the half testing _clamp_target_date_to_now, which was deleted with the GAM freshness validator call site. Duplication baseline refresh: 19 fingerprints surfaced by formatting normalization and recent merges (both halves of each pair predate this work); accepted via the hook's --update-baseline mechanism. Co-Authored-By: Claude Fable 5 --- .duplication-baseline | 26 ++++++--- tests/unit/test_a2a_transport_contract.py | 4 +- tests/unit/test_creative.py | 2 +- .../unit/test_creative_formats_behavioral.py | 52 +++++++++--------- tests/unit/test_delivery.py | 54 ++----------------- tests/unit/test_delivery_date_window.py | 31 ++--------- tests/unit/test_delivery_poll_behavioral.py | 2 +- tests/unit/test_openapi_surface.py | 4 +- tests/unit/test_rest_api_endpoints.py | 3 +- tests/unit/test_rest_api_products.py | 4 +- 10 files changed, 68 insertions(+), 114 deletions(-) diff --git a/.duplication-baseline b/.duplication-baseline index bf2cd47df0..37c41435a7 100644 --- a/.duplication-baseline +++ b/.duplication-baseline @@ -3,7 +3,6 @@ "src.adapters.gam.managers.reporting,src.services.delivery_simulator", "src.adapters.gam.managers.sync,src.admin.sync_api", "src.admin.api_schemas.tenant_management,src.admin.services.adapter_connection_tester", - "src.admin.blueprints.api,src.admin.blueprints.gam", "src.admin.blueprints.auth,src.admin.tests.unit.test_auth", "src.admin.blueprints.buyer_routing,src.admin.tenant_management_api", "src.admin.blueprints.creative_agents,src.admin.blueprints.signals_agents", @@ -17,6 +16,7 @@ "src.core.creative_agent_registry,src.core.format_resolver", "src.core.creative_agent_registry,src.core.signals_agent_registry", "src.core.schemas._base,src.core.schemas.account", + "src.core.tools.media_buy_create,src.services.order_approval_service", "src.services.delivery_webhook_scheduler,src.services.media_buy_status_scheduler" ], "tests": [ @@ -25,11 +25,11 @@ "tests.conftest_db,tests.fixtures.integration_db", "tests.e2e.test_a2a_adcp_compliance,tests.e2e.test_a2a_webhook_payload_types", "tests.e2e.test_a2a_adcp_compliance,tests.e2e.test_adcp_schema_compliance", - "tests.e2e.test_a2a_webhook_payload_types,tests.e2e.test_adcp_reference_implementation", "tests.e2e.test_a2a_webhook_payload_types,tests.e2e.test_delivery_webhooks_e2e", "tests.e2e.test_adcp_full_lifecycle,tests.e2e.test_adcp_reference_implementation", "tests.e2e.test_adcp_reference_implementation,tests.e2e.test_creative_assignment_e2e", "tests.e2e.test_adcp_reference_implementation,tests.e2e.test_delivery_webhooks_e2e", + "tests.integration._embedded_helpers,tests.integration.test_buyer_routing_page", "tests.integration._embedded_helpers,tests.integration.test_embed_breadcrumbs", "tests.integration._embedded_helpers,tests.integration.test_managed_mode_auth_bypass", "tests.integration.conftest,tests.integration.test_delivery_v3", @@ -37,31 +37,40 @@ "tests.integration.test_account_provisioning,tests.integration.test_sync_accounts_premap", "tests.integration.test_adapter_config_repository,tests.integration.test_gam_adapter_auth", "tests.integration.test_adapter_factory,tests.integration.test_gam_adapter_auth", + "tests.integration.test_basic_media_buy_lifecycle,tests.integration.test_create_media_buy_creation_paths", "tests.integration.test_basic_media_buy_lifecycle,tests.integration.test_gam_real_media_buy_lifecycle", "tests.integration.test_basic_media_buy_lifecycle,tests.integration.test_targeting_overlay_roundtrip", "tests.integration.test_buyer_advertiser_routing,tests.integration.test_gam_advertisers_cache", "tests.integration.test_buyer_routing_page,tests.integration.test_embed_breadcrumbs", + "tests.integration.test_buyer_routing_page,tests.integration.test_managed_mode_auth_bypass", + "tests.integration.test_create_media_buy_creation_paths,tests.integration.test_targeting_overlay_roundtrip", + "tests.integration.test_create_media_buy_creative_validation,tests.integration.test_create_media_buy_integration", + "tests.integration.test_creative_assignment_principal_id,tests.integration.test_media_buy_v3", "tests.integration.test_creative_async_lifecycle_obligations,tests.integration.test_creative_sync_processing", + "tests.integration.test_creative_formats_behavioral,tests.integration.test_creative_formats_protocol", "tests.integration.test_database_health_integration,tests.unit.test_database_health", "tests.integration.test_database_integration,tests.integration.test_tenant_settings_comprehensive", "tests.integration.test_delivery_service_behavioral,tests.unit.test_delivery_service_behavioral", "tests.integration.test_delivery_v3,tests.integration.test_media_buy_status_scheduler", + "tests.integration.test_delivery_v3,tests.unit.test_delivery", "tests.integration.test_delivery_v3,tests.unit.test_property_list_schema", "tests.integration.test_delivery_webhooks_force,tests.integration.test_delivery_webhooks_integration", - "tests.integration.test_embed_breadcrumbs,tests.integration.test_managed_mode_auth_bypass", "tests.integration.test_embed_breadcrumbs,tests.integration.test_managed_tenant_api", + "tests.integration.test_embed_breadcrumbs,tests.integration.test_managed_tenant_api_sprint3", + "tests.integration.test_error_paths,tests.unit.test_error_boundary_translation", "tests.integration.test_format_conversion_approval,tests.integration.test_media_buy_readiness", + "tests.integration.test_gam_advertisers_cache,tests.integration.test_managed_tenant_api", "tests.integration.test_gam_advertisers_cache,tests.integration.test_managed_tenant_api_sprint3", "tests.integration.test_gam_advertisers_cache,tests.integration.test_managed_tenant_api_sprint6", "tests.integration.test_gam_pricing_models_integration,tests.integration.test_gam_pricing_restriction", "tests.integration.test_gam_pricing_restriction,tests.integration.test_pricing_models_integration", - "tests.integration.test_get_products_anonymous_pricing,tests.integration.test_get_products_behavioral", + "tests.integration.test_get_products_anonymous_pricing,tests.integration.test_product_principal_access_pipeline", "tests.integration.test_get_products_behavioral,tests.integration.test_get_products_policy_obligations", "tests.integration.test_get_products_behavioral,tests.integration.test_get_products_response_constraints", - "tests.integration.test_get_products_policy_obligations,tests.integration.test_product_principal_access_pipeline", "tests.integration.test_inventory_profile_updates,tests.unit.test_inventory_profile_adcp_compliance", "tests.integration.test_managed_tenant_api_sprint3,tests.integration.test_managed_tenant_api_sprint6", "tests.integration.test_mcp_tool_roundtrip_validation,tests.integration.test_schema_database_mapping", + "tests.integration.test_media_buy_v3,tests.unit.test_media_buy", "tests.integration.test_mock_adapter,tests.unit.helpers.gam_mock_factory", "tests.integration.test_product_pricing_options_required,tests.integration.test_product_repository", "tests.integration.test_product_principal_access,tests.integration.test_product_with_inventory_profile", @@ -78,20 +87,25 @@ "tests.unit.test_architecture_bdd_no_duplicate_steps,tests.unit.test_architecture_bdd_no_trivial_assertions", "tests.unit.test_architecture_bdd_no_pass_steps,tests.unit.test_architecture_bdd_no_silent_env", "tests.unit.test_architecture_bdd_no_silent_env,tests.unit.test_architecture_bdd_no_trivial_assertions", + "tests.unit.test_architecture_boundary_completeness,tests.unit.test_boundary_field_forwarding", "tests.unit.test_architecture_no_raw_media_package_select,tests.unit.test_architecture_no_raw_select", "tests.unit.test_architecture_no_raw_select,tests.unit.test_creative_query_tenant_isolation", "tests.unit.test_architecture_no_raw_select,tests.unit.test_cross_tenant_query_isolation", "tests.unit.test_architecture_query_type_safety,tests.unit.test_no_toolerror_in_impl", "tests.unit.test_architecture_repository_pattern,tests.unit.test_architecture_weak_mock_assertions", "tests.unit.test_architecture_role_policy_declared,tests.unit.test_architecture_tenant_routes_decorated", + "tests.unit.test_auth_context_middleware_population,tests.unit.test_rest_depends_auth", "tests.unit.test_auth_requirements,tests.unit.test_sync_creatives_auth", + "tests.unit.test_create_media_buy_behavioral,tests.unit.test_create_media_buy_request_validation", "tests.unit.test_create_media_buy_behavioral,tests.unit.test_media_buy", "tests.unit.test_creative_query_tenant_isolation,tests.unit.test_cross_tenant_query_isolation", "tests.unit.test_creative_response_serialization,tests.unit.test_list_creatives_serialization", + "tests.unit.test_delivery,tests.unit.test_media_buy", "tests.unit.test_delivery_measurement_enforcement,tests.unit.test_product_schema_obligations", "tests.unit.test_format_id_dimension_merging,tests.unit.test_formatid_media_package", "tests.unit.test_gam_currency_detection,tests.unit.test_gam_orders_manager_advertisers", "tests.unit.test_gam_placement_targeting,tests.unit.test_performance_index_behavioral", - "tests.unit.test_get_products_impl_coverage,tests.unit.test_quiet_failure_propagation" + "tests.unit.test_get_products_impl_coverage,tests.unit.test_quiet_failure_propagation", + "tests.unit.test_product_mcp_wrapper,tests.unit.test_products_transport_wrappers" ] } diff --git a/tests/unit/test_a2a_transport_contract.py b/tests/unit/test_a2a_transport_contract.py index 4071d3c5aa..fb42e310c0 100644 --- a/tests/unit/test_a2a_transport_contract.py +++ b/tests/unit/test_a2a_transport_contract.py @@ -17,11 +17,13 @@ from unittest.mock import patch import pytest -from src.app import app from starlette.testclient import TestClient +from core.main import build_app from src.core.resolved_identity import ResolvedIdentity +app = build_app() + _MOCK_IDENTITY = ResolvedIdentity( principal_id="test-principal", tenant_id="test-tenant", diff --git a/tests/unit/test_creative.py b/tests/unit/test_creative.py index 19398cebc8..ed04d7c9a4 100644 --- a/tests/unit/test_creative.py +++ b/tests/unit/test_creative.py @@ -54,8 +54,8 @@ from unittest.mock import MagicMock, patch import pytest +from adcp.types import FormatId as AdcpFormatId from adcp.types.generated_poc.core.creative_asset import CreativeAsset -from adcp.types.generated_poc.core.format_id import FormatId as AdcpFormatId from adcp.types.generated_poc.enums.creative_action import CreativeAction from src.core.exceptions import AdCPAdapterError, AdCPAuthenticationError, AdCPValidationError diff --git a/tests/unit/test_creative_formats_behavioral.py b/tests/unit/test_creative_formats_behavioral.py index 097360d2e0..84be5d5e79 100644 --- a/tests/unit/test_creative_formats_behavioral.py +++ b/tests/unit/test_creative_formats_behavioral.py @@ -13,15 +13,15 @@ import pytest from adcp.types.generated_poc.core.format import ( Assets, - Assets5, + Assets25, Dimensions, Renders, ) -# adcp 3.9: Assets classes are type-discriminated by asset_type + item_type. -# Assets = individual image, Assets5 = individual video -# Assets18 = repeatable_group (has nested assets, no asset_type) -# Nested group assets: Assets19 (image), Assets20 (video), Assets22 (text), etc. +# adcp 6.4: Assets classes are type-discriminated by asset_type + item_type. +# Assets = individual image, Assets25 = individual video +# Assets39 = repeatable_group (has nested assets, no asset_type) +# Nested group assets: Assets41 (image), Assets42 (video), Assets44 (text), etc. from src.core.schemas import Format, FormatId, ListCreativeFormatsRequest from tests.factories import PrincipalFactory @@ -195,21 +195,21 @@ class TestAssetTypesFilterChecksGroupAssets: def test_asset_types_filter_finds_type_in_group_assets(self): """Format with group assets containing requested type should be included.""" - # adcp 3.9: repeatable_group uses Assets18, nested items use Assets19+ variants - from adcp.types.generated_poc.core.format import Assets18, Assets19, Assets22 + # adcp 6.4: repeatable_group uses Assets39, nested items use Assets41+ variants + from adcp.types.generated_poc.core.format import Assets39, Assets41, Assets44 - group_asset = Assets18( + group_asset = Assets39( item_type="repeatable_group", asset_group_id="product_group", required=True, min_count=1, max_count=5, assets=[ - Assets19( + Assets41( asset_id="product_image", required=True, ), - Assets22( + Assets44( asset_id="product_title", required=True, ), @@ -232,17 +232,17 @@ def test_asset_types_filter_finds_type_in_group_assets(self): def test_asset_types_filter_excludes_group_without_match(self): """Format with group assets NOT containing requested type should be excluded.""" - # adcp 3.9: repeatable_group uses Assets18, nested text items use Assets22 - from adcp.types.generated_poc.core.format import Assets18, Assets22 + # adcp 6.4: repeatable_group uses Assets39, nested text items use Assets44 + from adcp.types.generated_poc.core.format import Assets39, Assets44 - group_asset = Assets18( + group_asset = Assets39( item_type="repeatable_group", asset_group_id="text_group", required=True, min_count=1, max_count=3, assets=[ - Assets22( + Assets44( asset_id="headline", required=True, ), @@ -264,22 +264,22 @@ def test_asset_types_filter_excludes_group_without_match(self): def test_asset_types_filter_mixed_individual_and_group(self): """Format with both individual and group assets: filter checks both.""" - # adcp 3.9: Assets5 = individual video, Assets18 = repeatable_group - # Assets18 nested assets use Assets19+ classes (image=Assets19) - from adcp.types.generated_poc.core.format import Assets18, Assets19 + # adcp 6.4: Assets25 = individual video, Assets39 = repeatable_group + # Assets39 nested assets use Assets41+ classes (image=Assets41) + from adcp.types.generated_poc.core.format import Assets39, Assets41 - individual_asset = Assets5( + individual_asset = Assets25( asset_id="hero_video", required=True, ) - group_asset = Assets18( + group_asset = Assets39( item_type="repeatable_group", asset_group_id="product_group", required=False, min_count=0, max_count=5, assets=[ - Assets19( + Assets41( asset_id="product_image", required=True, ), @@ -442,8 +442,8 @@ class TestAssetTypesFilterExclusion: def test_format_with_non_matching_assets_excluded(self): """Format with assets that do not match any requested type is excluded.""" - # adcp 3.6.0: use typed asset classes - Assets (image), Assets9 (html) - from adcp.types.generated_poc.core.format import Assets9 + # adcp 6.4: use typed asset classes - Assets (image), Assets29 (html) + from adcp.types.generated_poc.core.format import Assets29 formats = [ _make_format( @@ -460,7 +460,7 @@ def test_format_with_non_matching_assets_excluded(self): "html_widget", "HTML Widget", assets=[ - Assets9( + Assets29( asset_id="code", required=True, ), @@ -476,7 +476,7 @@ def test_format_with_non_matching_assets_excluded(self): def test_format_with_assets_of_wrong_type_excluded_while_match_kept(self): """Only formats with at least one matching asset type are kept.""" - # adcp 3.6.0: Assets (image), Assets5 (video) + # adcp 6.4: Assets (image), Assets25 (video) formats = [ _make_format( "image_only", @@ -492,7 +492,7 @@ def test_format_with_assets_of_wrong_type_excluded_while_match_kept(self): "video_format", "Video Format", assets=[ - Assets5( + Assets25( asset_id="clip", required=True, ), @@ -511,7 +511,7 @@ class TestBroadstreetTemplateAssetParsing: """Regression: Broadstreet templates must parse with real assets. The production code uses _make_asset() to construct the correct Assets - variant class (Assets for image, Assets5 for video, etc.) for each + variant class (Assets for image, Assets25 for video, etc.) for each template asset. Previously, the code used Assets(asset_type=AssetContentType(...)) which failed because Assets.asset_type is Literal['image'], not an enum. """ diff --git a/tests/unit/test_delivery.py b/tests/unit/test_delivery.py index 7a71192898..30b417b356 100644 --- a/tests/unit/test_delivery.py +++ b/tests/unit/test_delivery.py @@ -43,11 +43,7 @@ ReportingPeriod, ) from src.core.testing_hooks import AdCPTestContext -from src.core.tools.media_buy_delivery import ( - _get_media_buy_delivery_impl, - get_media_buy_delivery, - get_media_buy_delivery_raw, -) +from src.core.tools.media_buy_delivery import _get_media_buy_delivery_impl from src.services.webhook_delivery_service import CircuitBreaker, CircuitState, WebhookDeliveryService from tests.harness.delivery_poll_unit import DeliveryPollEnv @@ -791,50 +787,10 @@ def test_valid_status_enum_values_accepted(self): ) assert isinstance(response, GetMediaBuyDeliveryResponse) - async def test_valid_status_enum_values_accepted_mcp(self): - """UC-004-FILT-07: valid status values accepted via MCP wrapper. - - Covers: UC-004-ALT-STATUS-FILTERED-DELIVERY-07 - - Route: mcp -- MCP wrapper accepts each MediaBuyStatus enum value. - """ - from unittest.mock import AsyncMock - - from fastmcp.server.context import Context - - for status in MediaBuyStatus: - with DeliveryPollEnv() as env: - env.add_buy(media_buy_id="mb_mcp") - env.set_adapter_response("mb_mcp", impressions=100) - - mock_ctx = AsyncMock(spec=Context) - mock_ctx.get_state.return_value = env.identity - - result = await get_media_buy_delivery( - media_buy_ids=["mb_mcp"], - status_filter=status, - ctx=mock_ctx, - ) - assert result.structured_content is not None - - def test_valid_status_enum_values_accepted_a2a(self): - """UC-004-FILT-07: valid status values accepted via A2A wrapper. - - Covers: UC-004-ALT-STATUS-FILTERED-DELIVERY-07 - - Route: a2a -- A2A raw function accepts each MediaBuyStatus enum value. - """ - for status in MediaBuyStatus: - with DeliveryPollEnv() as env: - env.add_buy(media_buy_id="mb_a2a") - env.set_adapter_response("mb_a2a", impressions=100) - - response = get_media_buy_delivery_raw( - media_buy_ids=["mb_a2a"], - status_filter=status, - identity=env.identity, - ) - assert isinstance(response, GetMediaBuyDeliveryResponse) + # NOTE: the flat-param MCP/A2A wrapper variants of UC-004-FILT-07 were + # removed with the wrappers themselves ("Sales Agent 2.0", 7fbd30ea8): + # transport dispatch is exercised via core/platforms/_delegate.py and the + # cross-transport harness, not per-tool wrapper functions. # =========================================================================== diff --git a/tests/unit/test_delivery_date_window.py b/tests/unit/test_delivery_date_window.py index 8a89569e1c..af0f69966f 100644 --- a/tests/unit/test_delivery_date_window.py +++ b/tests/unit/test_delivery_date_window.py @@ -1,22 +1,19 @@ """Tests for delivery-tool date window handling. -Covers tescoboy issues #149 and #170: +Covers tescoboy issue #149: - #149 ``_normalize_reporting_window``: same-day ``start_date == end_date`` was overwritten with ``now()`` and rejected as ``invalid_date_range``. AdCP defines ``start_date``/``end_date`` as inclusive date-only inputs, so same-day means the full 24-hour UTC day. -- #170 ``_clamp_target_date_to_now``: the freshness validator rejected any - window whose ``end_date`` extended past "now". Buyer queries with - future-dated ``end_date`` are legitimate; GAM cannot have data through - a date that hasn't happened, so the call site clamps - ``target_date = min(end, now)`` before validation. +(#170 ``_clamp_target_date_to_now`` was covered here too, but that helper +and the GAM freshness-validator call site were removed after the 2.0 +refactor — the clamp behavior has no current equivalent to test.) """ from datetime import UTC, datetime, timedelta -from src.adapters.google_ad_manager import _clamp_target_date_to_now from src.core.tools.media_buy_delivery import _normalize_reporting_window @@ -62,23 +59,3 @@ def test_only_end_supplied_falls_through_to_default(self): start, end, valid = _normalize_reporting_window(None, "2026-03-15") assert valid is True assert (datetime.now(UTC) - end) < timedelta(seconds=60) - - -class TestClampTargetDateToNow: - """`_clamp_target_date_to_now` clamps future end-dates back to now.""" - - def test_future_end_date_clamps_to_now(self): - future = datetime.now(UTC) + timedelta(days=1) - clamped = _clamp_target_date_to_now(future) - assert clamped < future - assert (datetime.now(UTC) - clamped) < timedelta(seconds=60) - - def test_past_end_date_unchanged(self): - past = datetime(2020, 1, 1, tzinfo=UTC) - assert _clamp_target_date_to_now(past) == past - - def test_naive_future_clamps_to_naive_now(self): - future = (datetime.now(UTC) + timedelta(days=1)).replace(tzinfo=None) - clamped = _clamp_target_date_to_now(future) - assert clamped.tzinfo is None - assert clamped < future diff --git a/tests/unit/test_delivery_poll_behavioral.py b/tests/unit/test_delivery_poll_behavioral.py index dd66721250..63b08a4b66 100644 --- a/tests/unit/test_delivery_poll_behavioral.py +++ b/tests/unit/test_delivery_poll_behavioral.py @@ -141,7 +141,7 @@ class TestValidStatusValuesAccepted: "status_input", [ MediaBuyStatus.active, - MediaBuyStatus.pending_activation, + MediaBuyStatus.pending_start, MediaBuyStatus.paused, MediaBuyStatus.completed, ], diff --git a/tests/unit/test_openapi_surface.py b/tests/unit/test_openapi_surface.py index 0c7ed71a26..07965463e8 100644 --- a/tests/unit/test_openapi_surface.py +++ b/tests/unit/test_openapi_surface.py @@ -9,9 +9,11 @@ beads: salesagent-b61l.16 """ -from src.app import app from starlette.testclient import TestClient +from core.main import build_app + +app = build_app() client = TestClient(app) diff --git a/tests/unit/test_rest_api_endpoints.py b/tests/unit/test_rest_api_endpoints.py index 11c38fcf80..da637c1739 100644 --- a/tests/unit/test_rest_api_endpoints.py +++ b/tests/unit/test_rest_api_endpoints.py @@ -10,11 +10,12 @@ from unittest.mock import MagicMock, patch -from src.app import app from starlette.testclient import TestClient +from core.main import build_app from src.core.resolved_identity import ResolvedIdentity +app = build_app() client = TestClient(app) _MOCK_IDENTITY = ResolvedIdentity( diff --git a/tests/unit/test_rest_api_products.py b/tests/unit/test_rest_api_products.py index 3979b17a20..d07930d58e 100644 --- a/tests/unit/test_rest_api_products.py +++ b/tests/unit/test_rest_api_products.py @@ -12,11 +12,13 @@ from unittest.mock import patch -from src.app import app from starlette.testclient import TestClient +from core.main import build_app from src.core.resolved_identity import ResolvedIdentity +app = build_app() + _MOCK_IDENTITY = ResolvedIdentity( principal_id="test-principal", tenant_id="default", From e17f6ac820e575b0c8bb5da115ec1700db3ea766 Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 16 Jul 2026 19:31:48 +0600 Subject: [PATCH 08/17] =?UTF-8?q?docs:=20session=20report=20=E2=80=94=20im?= =?UTF-8?q?provements,=20gate=20state,=20pre-existing=20debt=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../codebase-improvement-2026-07.md | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/development/codebase-improvement-2026-07.md diff --git a/docs/development/codebase-improvement-2026-07.md b/docs/development/codebase-improvement-2026-07.md new file mode 100644 index 0000000000..bf315af36a --- /dev/null +++ b/docs/development/codebase-improvement-2026-07.md @@ -0,0 +1,99 @@ +# Codebase Improvement Session — 2026-07-16 + +Branch: `feature/refactor-all` (base `26fa253bf`). Eight commits, `10cff0b0a..f300cc2cd`. + +## Verified end state (honest exit codes, no caches) + +| Gate step | Before session | After session | +|---|---|---| +| `ruff format --check .` | FAIL (13 drifted files) | **PASS** | +| `ruff check .` (--no-cache) | FAIL (103 latent errors) | **PASS** | +| `mypy src/` | FAIL (4 errors, cache-masked) | **PASS** (397 files) | +| duplication check | FAIL (stale baseline vs merges) | **PASS** | +| `pytest tests/unit/` | 5705 passed / 268 failed / 29 errors | 5962 passed (+257) / 307 failed / 24 errors | + +Unit-failure attribution was established by running the suite at the base commit in a clean +worktree and set-diffing FAILED lists: **zero failures in the current tree are regressions from +this session's changes.** The failed count rose only because 296 previously *uncollectable* +tests now run (255 pass, 41 expose their own drift — visible beats hidden). + +## Bugs fixed (production code) + +1. `src/admin/blueprints/products.py` `edit_product` — `NameError` (undefined `formats`) when + saving a GAM product with an explicit line-item type; now passes `product.format_ids`. +2. `src/admin/blueprints/api.py` product suggestions — `already_exists` was computed against the + first *character* of each product ID (`scalars()` rows are strings; `product[0]` sliced them). +3. `src/services/delivery_webhook_scheduler.py` — the AdCP `sequence_number` for scheduled + delivery webhooks was computed (max+1) then dropped: payloads never carried it and + `WebhookDeliveryLog` rows always recorded 1. Now assigned to the response. +4. `src/core/strategy.py` time-jump — assigned a naive datetime to the aware `current_time` + (aware/naive compare could raise `TypeError`); parsed target is now UTC-aware. +5. Three `date.today()` defaults (GAM forecast window, GAM order projection, update-media-buy) + → `datetime.now(UTC).date()` — deterministic across server timezones. +6. Dead `/api/gam/test-connection` + `/api/gam/get-advertisers` routes removed — unreferenced + legacy duplicates of the tenant-scoped routes; the former crashed with a swallowed + `NameError` so its advertiser-fetch never worked. +7. `src/admin/sync_api.py` — pass the concrete `Session` (proxy call) to `GAMOrdersService` + (4 mypy errors). +8. ~25 write-only variables, a try/except that only re-raised, a redundant `GAMAuthManager` + construction, a debug `print` in the mock adapter's media-buy path, an exception `print` + in `default_products` → logging. + +## Enforcement added (regressions now fail the build) + +- `E722`, `F821`, `F841`, `E741` removed from the ruff ignore list (fixed to zero in + `src/` + `scripts/`; tests keep a scoped per-file exemption). +- `RET501/505/506/507/508`, `PIE790` added to select after fixing ~298 sites. +- Duplication baseline refreshed via the hook's own `--update-baseline` (19 fingerprints + surfaced by formatting normalization/recent merges; both halves of each pair predate this work). + +## Process findings (important) + +- **Local gates were passing on stale caches.** `ruff check .` served a stale "clean" verdict + (`--no-cache` showed 103 real errors); mypy's incremental cache masked 4 errors. Consider + `--no-cache` in CI or periodic cache busts. +- **Session mistake, disclosed:** early gate runs were piped to `tail`, so pipe exit codes + masked failures and three commits landed while the tree was red on steps later in the gate. + All steps were subsequently re-run bare and are green through step 4 (pytest carries only + pre-existing debt). +- `.claude/research` is a broken symlink to `/Users/konst/projects/salesagent/.claude/research` + (another contributor's macOS home) — hence this report living in `docs/development/`. + +## Pre-existing debt map (needs maintainer decisions) + +All of this predates the session (verified at base commit); root cause is the +"Sales Agent 2.0" refactor `7fbd30ea8` + a2a-sdk 1.0.1 / adcp 6.4 bumps: + +1. **11 obsolete unit test files (2,444 lines) that can never collect** — they import surfaces + deleted by 2.0 with no successor. Deletion was prepared but **intentionally left to you** + (auth-related coverage is included). Delete or rewrite against the harness transport layer: + `test_a2a_auth_optional`, `test_a2a_handler_correctness`, `test_error_format_consistency` + (821 lines — best rewrite candidate), `test_a2a_brand_manifest_parameter`, + `test_a2a_nl_auth_redundancy`, `test_a2a_testing_context_extraction`, + `test_adapter_packages_fix` (Kevel/Xandr deleted, Triton parked), + `test_task_management_auth`, `test_update_media_buy_transport_wrappers`, + `test_v2_compat_version_gating`, `test_mcp_schema_validator`. + Integration/e2e suites reference the same deleted modules (~25 files total). +2. **4 REST/transport unit files** now import correctly but `core.main.build_app()` fail-fasts + on the DB health check in a DB-less env → need a DB-less bootstrap mode or a move to + `tests/integration/`. +3. **307 failing unit tests** — SDK-drift assertion failures concentrated in creative/delivery/ + a2a areas (27 in `test_creative.py` alone). +4. **Stale `__pycache__` of deleted modules** — `src/a2a_server/` + `src/routes/` were moved to + the session scratchpad (`quarantined-stale-pycache/`); a guard test demands they not exist. + Delete them permanently at your convenience. +5. **6 F821 undefined names inside tests/** (exempted today via per-file-ignores). + +## Recommended next steps (priority order) + +1. Decide on the 11 obsolete files; rewrite error-format coverage against + `core/platforms/_delegate.py` via the harness (`call_a2a`/`call_mcp`). +2. Burn down the 307 failures suite-by-suite (creative first: 27 in one file). +3. mypy strictness step 1 (`warn_return_any = True`) — measured at **90 errors in 43 files**. +4. Reviewed migration of ~187 `logger.error` → `logger.exception` in except blocks (TRY400) — + NOT bulk-applicable: at least one site (`src/adapters/gam/auth.py:58`) deliberately logs + short messages to avoid leaking SA-key fragments. +5. Complexity hotspots: 58 functions above complexity 20; worst are + `_create_media_buy_impl` (239), `_update_media_buy_impl` (126), `edit_product` (87), + `_get_products_impl` (87), GAM `create_line_items` (83), `add_product` (82). +6. Relocate `src/services/ai_parsing_comparison.py` (unimported CLI tool, 67 prints) to `scripts/`. From 9114585ce508560ce331b794ae52c01738a50279 Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 16 Jul 2026 20:15:07 +0600 Subject: [PATCH 09/17] refactor: eliminate all 86 no-any-return sites and enable warn_return_any MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Executes step 1 of mypy.ini's documented strictness roadmap ("bugs hide in Any returns"). Every function returning Any where a concrete type is declared now uses a typed intermediate or cast — no behavior changes, no type: ignore, no annotations loosened. Verified with a cold (--no-incremental) mypy run over all of src/ and an unchanged unit-suite result (307/5962/24, byte-identical totals). One annotation was genuinely wrong and is corrected: json_type.process_bind_param deliberately passes BaseModel through to the engine's pydantic serializer but declared it could not. Latent bugs noted for follow-up (not changed here): broadstreet delete_* endpoints can return None where dict is declared (empty 204 bodies); broadstreet create_placement skips the response-envelope unwrap its siblings do; mock adapter _is_simulation returns None instead of False; `from adcp import WholesaleFeedEvent` types as Any despite the adcp mypy plugin (upstream re-export gap). Co-Authored-By: Claude Fable 5 --- mypy.ini | 12 ++++----- scripts/ops/gam_helper.py | 2 +- src/adapters/__init__.py | 6 +++-- src/adapters/broadstreet/client.py | 16 +++++++++--- src/adapters/freewheel/_creatives.py | 6 +++-- src/adapters/gam/managers/creatives.py | 5 ++-- src/adapters/gam/managers/inventory.py | 15 +++++++---- src/adapters/gam_orders_discovery.py | 3 ++- src/adapters/google_ad_manager.py | 3 ++- src/adapters/mock_ad_server.py | 14 +++++----- src/adapters/springserve/_demand_tags.py | 3 ++- src/adapters/triton/client.py | 26 ++++++++++++------- src/admin/app.py | 3 ++- src/admin/blueprints/adapters.py | 2 +- src/admin/blueprints/inventory_profiles.py | 3 ++- src/admin/blueprints/products.py | 2 +- src/admin/services/dashboard_service.py | 13 +++++----- src/admin/services/sync_webhook_emission.py | 3 ++- src/admin/tenant_management_api.py | 6 +++-- src/core/auth.py | 2 +- src/core/auth_utils.py | 3 ++- src/core/creative_agent_registry.py | 12 ++++++--- src/core/database/database_session.py | 3 ++- src/core/database/json_type.py | 5 ++-- src/core/format_cache.py | 3 ++- src/core/format_resolver.py | 8 +++--- src/core/helpers/context_helpers.py | 5 ++-- src/core/http_utils.py | 3 ++- src/core/request_compat.py | 3 ++- src/core/strategy.py | 9 ++++--- src/core/tenant_context.py | 3 +++ src/core/tools/media_buy_create.py | 8 +++--- src/core/tools/signals.py | 3 ++- src/core/tools/workflow_serialization.py | 3 ++- src/core/utils/naming.py | 5 ++-- src/core/version.py | 3 ++- src/landing/landing_page.py | 3 ++- src/services/background_sync_service.py | 2 +- src/services/dynamic_products.py | 4 +-- src/services/protocol_change_webhooks.py | 6 +++-- src/services/protocol_webhook_service.py | 4 +-- .../push_notification_registration.py | 3 ++- src/services/webhook_delivery_service.py | 2 +- 43 files changed, 157 insertions(+), 91 deletions(-) diff --git a/mypy.ini b/mypy.ini index ad0b741ee9..2423c7c9b8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -8,12 +8,12 @@ python_version = 3.13 # Strictness settings - start lenient, gradually increase. # TODO: enable incrementally — adcp 5.0 cleanup dropped error counts ~50%. -# Recommended order (lowest cost first, also highest bug-finding signal first): -# 1. warn_return_any = True (80 errors — bugs hide in Any returns) -# 2. check_untyped_defs = True (203 errors — interpretive) -# 3. disallow_incomplete_defs = True (273 errors — mechanical annotation) -# Pre-5.0 baselines were 198 / 431 / 368 respectively. -warn_return_any = False +# Remaining order (lowest cost first, also highest bug-finding signal first): +# 1. check_untyped_defs = True (203 errors — interpretive) +# 2. disallow_incomplete_defs = True (273 errors — mechanical annotation) +# Pre-5.0 baselines were 431 / 368 respectively. +# warn_return_any: enabled 2026-07 after fixing all 86 no-any-return sites. +warn_return_any = True warn_unused_configs = True warn_unused_ignores = True disallow_untyped_defs = False diff --git a/scripts/ops/gam_helper.py b/scripts/ops/gam_helper.py index 67e25b3845..9526a95bdf 100644 --- a/scripts/ops/gam_helper.py +++ b/scripts/ops/gam_helper.py @@ -158,7 +158,7 @@ def ensure_network_timezone(tenant_id: str) -> str: logger.info(f"Fetching network timezone from GAM for tenant {tenant_id}...") try: network_info = get_gam_network_info(tenant_id) - timezone = network_info.get("timezone", "America/New_York") + timezone: str = network_info.get("timezone", "America/New_York") logger.info(f"Got network timezone for tenant {tenant_id}: {timezone}") return timezone except Exception as e: diff --git a/src/adapters/__init__.py b/src/adapters/__init__.py index 7be4b7ec67..29d2f1db07 100644 --- a/src/adapters/__init__.py +++ b/src/adapters/__init__.py @@ -89,7 +89,8 @@ def get_adapter_default_channels(adapter_type: str) -> list[str]: """ adapter_class = ADAPTER_REGISTRY.get(adapter_type) if adapter_class and hasattr(adapter_class, "default_channels"): - return adapter_class.default_channels + channels: list[str] = adapter_class.default_channels + return channels return [] @@ -108,5 +109,6 @@ def get_adapter_default_delivery_measurement(adapter_type: str) -> dict[str, str """ adapter_class = ADAPTER_REGISTRY.get(adapter_type) if adapter_class and hasattr(adapter_class, "default_delivery_measurement"): - return adapter_class.default_delivery_measurement + measurement: dict[str, str] = adapter_class.default_delivery_measurement + return measurement return {"provider": "publisher"} diff --git a/src/adapters/broadstreet/client.py b/src/adapters/broadstreet/client.py index 8543b544cf..6c58850f9a 100644 --- a/src/adapters/broadstreet/client.py +++ b/src/adapters/broadstreet/client.py @@ -243,7 +243,10 @@ def create_campaign( def delete_campaign(self, advertiser_id: str, campaign_id: str) -> dict[str, Any]: """Delete a campaign.""" - return self.delete(f"/networks/{self.network_id}/advertisers/{advertiser_id}/campaigns/{campaign_id}") + result: dict[str, Any] = self.delete( + f"/networks/{self.network_id}/advertisers/{advertiser_id}/campaigns/{campaign_id}" + ) + return result # ========================================================================= # Advertisement Operations @@ -324,7 +327,10 @@ def set_advertisement_source( def delete_advertisement(self, advertiser_id: str, advertisement_id: str) -> dict[str, Any]: """Delete an advertisement.""" - return self.delete(f"/networks/{self.network_id}/advertisers/{advertiser_id}/advertisements/{advertisement_id}") + result: dict[str, Any] = self.delete( + f"/networks/{self.network_id}/advertisers/{advertiser_id}/advertisements/{advertisement_id}" + ) + return result def get_advertisement_report( self, @@ -382,10 +388,11 @@ def create_placement( "zone_id": zone_id, "advertisement_id": advertisement_id, } - return self.post( + result: dict[str, Any] = self.post( f"/networks/{self.network_id}/advertisers/{advertiser_id}/campaigns/{campaign_id}/placements", data, ) + return result # ========================================================================= # Zone Operations @@ -422,4 +429,5 @@ def create_zone( def delete_zone(self, zone_id: str) -> dict[str, Any]: """Delete a zone.""" - return self.delete(f"/networks/{self.network_id}/zones/{zone_id}") + result: dict[str, Any] = self.delete(f"/networks/{self.network_id}/zones/{zone_id}") + return result diff --git a/src/adapters/freewheel/_creatives.py b/src/adapters/freewheel/_creatives.py index 3c1910d2b5..e1f11c359e 100644 --- a/src/adapters/freewheel/_creatives.py +++ b/src/adapters/freewheel/_creatives.py @@ -46,10 +46,12 @@ def _unwrap_creative(envelope: dict[str, Any]) -> dict[str, Any]: wrapper key is present. """ if "creative" in envelope: - return envelope["creative"] + creative: dict[str, Any] = envelope["creative"] + return creative data = envelope.get("data") if isinstance(data, dict) and "creative" in data: - return data["creative"] + creative = data["creative"] + return creative return envelope diff --git a/src/adapters/gam/managers/creatives.py b/src/adapters/gam/managers/creatives.py index 1987c3bf56..fa227ac330 100644 --- a/src/adapters/gam/managers/creatives.py +++ b/src/adapters/gam/managers/creatives.py @@ -783,7 +783,7 @@ def _get_html5_source(self, asset: dict[str, Any]) -> str: if asset.get("media_data"): try: # Decode base64 if needed - content = asset["media_data"] + content: str = asset["media_data"] if content.startswith("data:"): # Extract base64 part after comma content = content.split(",", 1)[1] @@ -823,7 +823,8 @@ def _get_content_type(self, asset: dict[str, Any]) -> str: """Determine content type from asset.""" # Check explicit mime type if asset.get("mime_type"): - return asset["mime_type"] + mime_type: str = asset["mime_type"] + return mime_type # Guess from URL extension url = asset.get("media_url") or asset.get("url", "") diff --git a/src/adapters/gam/managers/inventory.py b/src/adapters/gam/managers/inventory.py index 3ecadbcb65..e194812ffc 100644 --- a/src/adapters/gam/managers/inventory.py +++ b/src/adapters/gam/managers/inventory.py @@ -78,7 +78,8 @@ def discover_ad_units(self, parent_id: str | None = None, max_depth: int = 10) - return [] discovery = self._get_discovery() - return discovery.discover_ad_units(parent_id, max_depth) + ad_units: list[AdUnit] = discovery.discover_ad_units(parent_id, max_depth) + return ad_units def discover_placements(self) -> list[Placement]: """Discover all placements in the GAM network. @@ -93,7 +94,8 @@ def discover_placements(self) -> list[Placement]: return [] discovery = self._get_discovery() - return discovery.discover_placements() + placements: list[Placement] = discovery.discover_placements() + return placements def discover_custom_targeting(self) -> dict[str, Any]: """Discover all custom targeting keys and their values. @@ -108,7 +110,8 @@ def discover_custom_targeting(self) -> dict[str, Any]: return {"keys": [], "total_values": 0} discovery = self._get_discovery() - return discovery.discover_custom_targeting() + custom_targeting: dict[str, Any] = discovery.discover_custom_targeting() + return custom_targeting def discover_audience_segments(self) -> list[AudienceSegment]: """Discover audience segments (first-party and third-party). @@ -123,7 +126,8 @@ def discover_audience_segments(self) -> list[AudienceSegment]: return [] discovery = self._get_discovery() - return discovery.discover_audience_segments() + segments: list[AudienceSegment] = discovery.discover_audience_segments() + return segments def discover_labels(self) -> list[Label]: """Discover all labels (for competitive exclusion, etc.). @@ -138,7 +142,8 @@ def discover_labels(self) -> list[Label]: return [] discovery = self._get_discovery() - return discovery.discover_labels() + labels: list[Label] = discovery.discover_labels() + return labels def sync_all_inventory(self, custom_targeting_limit: int = 1000, fetch_values: bool = False) -> dict[str, Any]: """Perform full inventory sync from GAM. diff --git a/src/adapters/gam_orders_discovery.py b/src/adapters/gam_orders_discovery.py index a3bee75f13..612ea126be 100644 --- a/src/adapters/gam_orders_discovery.py +++ b/src/adapters/gam_orders_discovery.py @@ -307,7 +307,8 @@ def _safe_serialize(cls, obj: Any, field_name: str, line_item_id: str) -> dict[s if obj is None: return None try: - return serialize_object(obj) + serialized: dict[str, Any] = serialize_object(obj) + return serialized except Exception as e: logger.warning(f"Failed to serialize {field_name} for line item {line_item_id}: {e}") return None diff --git a/src/adapters/google_ad_manager.py b/src/adapters/google_ad_manager.py index 99bbcdd33e..e5a7eed8dd 100644 --- a/src/adapters/google_ad_manager.py +++ b/src/adapters/google_ad_manager.py @@ -950,7 +950,8 @@ def archive_order(self, order_id: str) -> bool: "[red]Error: GAM adapter not configured for order operations (missing advertiser_id or trafficker_id)[/red]" ) return False - return self.orders_manager.archive_order(order_id) + archived: bool = self.orders_manager.archive_order(order_id) + return archived def get_advertisers( self, search_query: str | None = None, limit: int = 500, fetch_all: bool = False diff --git a/src/adapters/mock_ad_server.py b/src/adapters/mock_ad_server.py index 7a688c28d0..c1a531cf60 100644 --- a/src/adapters/mock_ad_server.py +++ b/src/adapters/mock_ad_server.py @@ -94,7 +94,7 @@ def __init__(self, config, principal, dry_run=False, creative_engine=None, tenan # Store strategy context for simulation behavior self.strategy_context = strategy_context - self._current_simulation_time = None + self._current_simulation_time: datetime | None = None # Initialize HITL configuration from principal's platform_mappings self._initialize_hitl_config() @@ -114,7 +114,8 @@ def _should_force_error(self, error_type: str) -> bool: if not self._is_simulation() or not self.strategy_context: return False if hasattr(self.strategy_context, "should_force_error"): - return self.strategy_context.should_force_error(error_type) + forced: bool = self.strategy_context.should_force_error(error_type) + return forced return False def _get_simulation_scenario(self) -> str: @@ -122,7 +123,8 @@ def _get_simulation_scenario(self) -> str: if not self._is_simulation() or not self.strategy_context: return "normal" if hasattr(self.strategy_context, "get_config_value"): - return self.strategy_context.get_config_value("scenario", "normal") + scenario: str = self.strategy_context.get_config_value("scenario", "normal") + return scenario return "normal" def _apply_strategy_multipliers(self, base_value: float, multiplier_key: str) -> float: @@ -131,7 +133,7 @@ def _apply_strategy_multipliers(self, base_value: float, multiplier_key: str) -> return base_value if hasattr(self.strategy_context, "get_config_value"): - multiplier = self.strategy_context.get_config_value(multiplier_key, 1.0) + multiplier: float = self.strategy_context.get_config_value(multiplier_key, 1.0) return base_value * multiplier return base_value @@ -219,7 +221,7 @@ def _initialize_hitl_config(self): # Parse HITL settings with defaults self.hitl_enabled = self.hitl_config.get("enabled", False) - self.hitl_mode = self.hitl_config.get("mode", "sync") # "sync" | "async" | "mixed" + self.hitl_mode: str = self.hitl_config.get("mode", "sync") # "sync" | "async" | "mixed" # Sync mode settings sync_settings = self.hitl_config.get("sync_settings", {}) @@ -235,7 +237,7 @@ def _initialize_hitl_config(self): self.webhook_on_complete = async_settings.get("webhook_on_complete", True) # Per-operation mode overrides - self.operation_modes = self.hitl_config.get("operation_modes", {}) + self.operation_modes: dict[str, str] = self.hitl_config.get("operation_modes", {}) # Approval simulation settings approval_sim = self.hitl_config.get("approval_simulation", {}) diff --git a/src/adapters/springserve/_demand_tags.py b/src/adapters/springserve/_demand_tags.py index 2a8c6d27bc..62fa768caf 100644 --- a/src/adapters/springserve/_demand_tags.py +++ b/src/adapters/springserve/_demand_tags.py @@ -219,4 +219,5 @@ def add_kv_entry( body["value_ids"] = list(value_ids) if value_list_ids: body["value_list_ids"] = list(value_list_ids) - return self._transport.post_json(f"/demand_tags/{demand_tag_id}/demand_tag_keys", body) + result: dict[str, Any] = self._transport.post_json(f"/demand_tags/{demand_tag_id}/demand_tag_keys", body) + return result diff --git a/src/adapters/triton/client.py b/src/adapters/triton/client.py index 791052a70f..ddc08e04cf 100644 --- a/src/adapters/triton/client.py +++ b/src/adapters/triton/client.py @@ -83,7 +83,7 @@ def login(self) -> str: status_code=response.status_code, body=response.text, ) - token = response.json().get("access_token") or response.json().get("token") + token: str | None = response.json().get("access_token") or response.json().get("token") if not token: raise TritonAPIError( "Triton login response missing access_token", @@ -121,31 +121,39 @@ def _request(self, method: str, path: str, *, json: Any = None, params: Any = No # ----- entity operations ----- def create_campaign(self, advertiser_id: str, payload: dict[str, Any]) -> dict[str, Any]: - return self._request("POST", f"/advertisers/{advertiser_id}/campaigns", json=payload) + result: dict[str, Any] = self._request("POST", f"/advertisers/{advertiser_id}/campaigns", json=payload) + return result def create_flight(self, campaign_id: str, payload: dict[str, Any]) -> dict[str, Any]: - return self._request("POST", f"/campaigns/{campaign_id}/flights", json=payload) + result: dict[str, Any] = self._request("POST", f"/campaigns/{campaign_id}/flights", json=payload) + return result def update_flight(self, flight_id: str, payload: dict[str, Any]) -> dict[str, Any]: - return self._request("PATCH", f"/flights/{flight_id}", json=payload) + result: dict[str, Any] = self._request("PATCH", f"/flights/{flight_id}", json=payload) + return result def update_campaign(self, campaign_id: str, payload: dict[str, Any]) -> dict[str, Any]: - return self._request("PATCH", f"/campaigns/{campaign_id}", json=payload) + result: dict[str, Any] = self._request("PATCH", f"/campaigns/{campaign_id}", json=payload) + return result def get_campaign(self, campaign_id: str) -> dict[str, Any]: - return self._request("GET", f"/campaigns/{campaign_id}") + result: dict[str, Any] = self._request("GET", f"/campaigns/{campaign_id}") + return result def list_flights(self, campaign_id: str) -> list[dict[str, Any]]: result = self._request("GET", f"/campaigns/{campaign_id}/flights") - return result.get("items", []) if isinstance(result, dict) else result + items: list[dict[str, Any]] = result.get("items", []) if isinstance(result, dict) else result + return items def list_stations(self) -> list[dict[str, Any]]: result = self._request("GET", "/stations") - return result.get("items", []) if isinstance(result, dict) else result + items: list[dict[str, Any]] = result.get("items", []) if isinstance(result, dict) else result + return items def get_publisher(self) -> dict[str, Any]: """Return the publisher record associated with the JWT. Useful as a connectivity test — if this returns 200, credentials are valid. """ - return self._request("GET", "/publisher") + result: dict[str, Any] = self._request("GET", "/publisher") + return result diff --git a/src/admin/app.py b/src/admin/app.py index 066e9afcbe..c7c62f72b0 100644 --- a/src/admin/app.py +++ b/src/admin/app.py @@ -95,7 +95,8 @@ def _sanitize_for_log(value, *, max_len: int = 200) -> str: value = str(value) if len(value) > max_len: value = value[:max_len] + "…" - return value.replace("\r", "\\r").replace("\n", "\\n").replace("\x00", "\\x00") + sanitized: str = value.replace("\r", "\\r").replace("\n", "\\n").replace("\x00", "\\x00") + return sanitized # Custom ProxyFix for handling X-Script-Name and fixing redirect URLs diff --git a/src/admin/blueprints/adapters.py b/src/admin/blueprints/adapters.py index af67d7dee1..8418d0a1e2 100644 --- a/src/admin/blueprints/adapters.py +++ b/src/admin/blueprints/adapters.py @@ -22,7 +22,7 @@ def _freewheel_auth_mode(config_data: dict) -> str | None: """Return the intended FreeWheel auth mode and remove UI-only metadata.""" - explicit_mode = config_data.pop("auth_mode", None) + explicit_mode: str | None = config_data.pop("auth_mode", None) if explicit_mode in {"password_grant", "api_token", "client_credentials"}: return explicit_mode if config_data.get("client_id"): diff --git a/src/admin/blueprints/inventory_profiles.py b/src/admin/blueprints/inventory_profiles.py index 841a0ba7f5..bfa063fa86 100644 --- a/src/admin/blueprints/inventory_profiles.py +++ b/src/admin/blueprints/inventory_profiles.py @@ -922,7 +922,8 @@ def _placement_subkind(row, child_ids: list[str]) -> str: metadata = (row.raw or {}).get("metadata") or {} explicit = metadata.get("bundle_kind") or metadata.get("placement_kind") or metadata.get("kind") if explicit in {"site", "tag"}: - return explicit + subkind: str = explicit + return subkind return "site" if child_ids else "tag" diff --git a/src/admin/blueprints/products.py b/src/admin/blueprints/products.py index b9b66a3562..4bab3dbedb 100644 --- a/src/admin/blueprints/products.py +++ b/src/admin/blueprints/products.py @@ -168,7 +168,7 @@ def _format_id_to_display_name(format_id: str) -> str: def _format_error_to_dict(error: Any) -> dict[str, Any]: """Serialize an AdCP error object for tenant-management/admin responses.""" if hasattr(error, "model_dump"): - data = error.model_dump(mode="json", exclude_none=True) + data: dict[str, Any] = error.model_dump(mode="json", exclude_none=True) elif isinstance(error, dict): data = {key: value for key, value in error.items() if value is not None} else: diff --git a/src/admin/services/dashboard_service.py b/src/admin/services/dashboard_service.py index 393498606b..248fee353f 100644 --- a/src/admin/services/dashboard_service.py +++ b/src/admin/services/dashboard_service.py @@ -234,8 +234,8 @@ def _calculate_revenue_change(self, revenue_data: list[dict[str, Any]]) -> float if len(revenue_data) < 14: return 0.0 - last_week_revenue = sum(d["revenue"] for d in revenue_data[-7:]) - previous_week_revenue = sum(d["revenue"] for d in revenue_data[-14:-7]) + last_week_revenue: float = sum(d["revenue"] for d in revenue_data[-7:]) + previous_week_revenue: float = sum(d["revenue"] for d in revenue_data[-14:-7]) if previous_week_revenue > 0: return ((last_week_revenue - previous_week_revenue) / previous_week_revenue) * 100 @@ -280,8 +280,8 @@ def _calculate_estimated_spend(self, media_buy) -> float: return budget # Calculate spend based on elapsed days - total_days = (media_buy.end_date - media_buy.start_date).days + 1 - elapsed_days = (today - media_buy.start_date).days + 1 + total_days: int = (media_buy.end_date - media_buy.start_date).days + 1 + elapsed_days: int = (today - media_buy.start_date).days + 1 if total_days > 0: return budget * (elapsed_days / total_days) @@ -309,7 +309,7 @@ def _format_relative_time(self, timestamp) -> str: if delta.days < 30: weeks = delta.days // 7 return f"{weeks} week{'s' if weeks != 1 else ''} ago" - return timestamp.strftime("%Y-%m-%d") + return type_cast(str, timestamp.strftime("%Y-%m-%d")) hours = delta.seconds // 3600 if hours > 0: @@ -408,7 +408,8 @@ def _load_tenant(self, session) -> Tenant | None: from sqlalchemy import select stmt = select(Tenant).filter_by(tenant_id=self.tenant_id) - return session.scalars(stmt).first() + tenant: Tenant | None = session.scalars(stmt).first() + return tenant def _masthead(self, session, tenant: Tenant | None) -> dict[str, Any]: from sqlalchemy import func, select diff --git a/src/admin/services/sync_webhook_emission.py b/src/admin/services/sync_webhook_emission.py index 36a3eb197f..f9b78d6ebf 100644 --- a/src/admin/services/sync_webhook_emission.py +++ b/src/admin/services/sync_webhook_emission.py @@ -566,7 +566,8 @@ def _iso(value: Any) -> str | None: """Render a ``datetime`` as ISO-8601 with timezone, or ``None``.""" if value is None: return None - return value.isoformat() + rendered: str = value.isoformat() + return rendered def _public_error_message(raw: str | None) -> str | None: diff --git a/src/admin/tenant_management_api.py b/src/admin/tenant_management_api.py index 52727ff7fb..e75a6ed934 100644 --- a/src/admin/tenant_management_api.py +++ b/src/admin/tenant_management_api.py @@ -299,7 +299,8 @@ def _pydantic_error_details(exc: PydanticValidationError) -> list[dict[str, Any] safe_errors: list[dict[str, Any]] = [] for error in exc.errors(): safe_errors.append({key: value for key, value in error.items() if key in {"type", "loc", "msg", "url"}}) - return json.loads(json.dumps(safe_errors, default=str)) + json_safe_errors: list[dict[str, Any]] = json.loads(json.dumps(safe_errors, default=str)) + return json_safe_errors def _template_macro_names(macros: list[dict[str, str]]) -> set[str]: @@ -5301,7 +5302,8 @@ def _find_account_by_natural_key(session, tenant_id: str, req: CreateAccountRequ stmt = stmt.where(Account.brand["brand_id"].as_string() == req.brand.brand_id) if req.billing == "agent" and req.buyer_agent_principal_id: stmt = stmt.where(Account.principal_id == req.buyer_agent_principal_id) - return session.scalars(stmt).first() + account: Account | None = session.scalars(stmt).first() + return account def _grant_account_access_for_existing_principal( diff --git a/src/core/auth.py b/src/core/auth.py index 768b6d67aa..4b1ef896d7 100644 --- a/src/core/auth.py +++ b/src/core/auth.py @@ -357,7 +357,7 @@ def _execute(session_factory): return principal.principal_id if principal else None with get_db_session() as session: - resolved_principal_id = _execute(session) + resolved_principal_id: str | None = _execute(session) if resolved_principal_id: if _VERBOSE_AUTH_LOG: diff --git a/src/core/auth_utils.py b/src/core/auth_utils.py index b8a124cc93..badd406dcf 100644 --- a/src/core/auth_utils.py +++ b/src/core/auth_utils.py @@ -75,7 +75,8 @@ def _lookup_principal(session): return None, None try: - return execute_with_retry(_lookup_principal) + result: tuple[str | None, dict | None] = execute_with_retry(_lookup_principal) + return result except Exception as e: logger.error(f"[AUTH] Database error during principal lookup: {e}", exc_info=True) return None, None diff --git a/src/core/creative_agent_registry.py b/src/core/creative_agent_registry.py index b2408c980e..2c71f63b02 100644 --- a/src/core/creative_agent_registry.py +++ b/src/core/creative_agent_registry.py @@ -934,7 +934,8 @@ async def preview_creative( # Use structured_content field for JSON response (MCP protocol update) if hasattr(result, "structured_content") and result.structured_content: - return result.structured_content + structured: dict[str, Any] = result.structured_content + return structured # Fallback: Parse result from content field (legacy) import json @@ -943,7 +944,8 @@ async def preview_creative( preview_data = result.content[0].text if hasattr(result.content[0], "text") else result.content[0] if isinstance(preview_data, str): preview_data = json.loads(preview_data) - return preview_data + preview_payload: dict[str, Any] = preview_data + return preview_payload return {} @@ -998,7 +1000,8 @@ async def build_creative( # Use structured_content field for JSON response (MCP protocol update) if hasattr(result, "structured_content") and result.structured_content: - return result.structured_content + structured: dict[str, Any] = result.structured_content + return structured # Fallback: Parse result from content field (legacy) import json @@ -1007,7 +1010,8 @@ async def build_creative( creative_data = result.content[0].text if hasattr(result.content[0], "text") else result.content[0] if isinstance(creative_data, str): creative_data = json.loads(creative_data) - return creative_data + creative_payload: dict[str, Any] = creative_data + return creative_payload return {} diff --git a/src/core/database/database_session.py b/src/core/database/database_session.py index 9c47dab5a0..8b56c8eb15 100644 --- a/src/core/database/database_session.py +++ b/src/core/database/database_session.py @@ -226,7 +226,8 @@ def _create_db_session() -> Session: get_engine() if _session_factory is None: raise RuntimeError("Database session factory was not initialized") - return _session_factory() + session: Session = _session_factory() + return session @contextmanager diff --git a/src/core/database/json_type.py b/src/core/database/json_type.py index 944d21e174..d645a5b8f0 100644 --- a/src/core/database/json_type.py +++ b/src/core/database/json_type.py @@ -62,7 +62,7 @@ def __init__( self._is_list = is_list super().__init__(*args, **kwargs) - def process_bind_param(self, value: Any, dialect: Dialect) -> dict | list | None: + def process_bind_param(self, value: Any, dialect: Dialect) -> dict | list | BaseModel | None: """Serialize value for database storage. Accepts Pydantic models, dicts, and lists. Pydantic models are @@ -81,7 +81,8 @@ def process_bind_param(self, value: Any, dialect: Dialect) -> dict | list | None ) value = {} - return value + bind_value: dict | list | BaseModel = value + return bind_value def process_result_value(self, value: Any, dialect: Dialect) -> Any: """Deserialize value from database, coercing to Pydantic model if configured.""" diff --git a/src/core/format_cache.py b/src/core/format_cache.py index 0a71925cdc..c02f71e557 100644 --- a/src/core/format_cache.py +++ b/src/core/format_cache.py @@ -155,7 +155,8 @@ def load_format_cache() -> dict[str, str]: try: with open(CACHE_FILE) as f: data = json.load(f) - return data.get("formats", {}) + formats: dict[str, str] = data.get("formats", {}) + return formats except (OSError, json.JSONDecodeError): return {} diff --git a/src/core/format_resolver.py b/src/core/format_resolver.py index e3bddea6f4..c5d3ffa732 100644 --- a/src/core/format_resolver.py +++ b/src/core/format_resolver.py @@ -164,12 +164,12 @@ def get_format( # If agent_url provided, get format directly from that agent # Coerce to str: FormatId.agent_url is Pydantic AnyUrl (not a str subclass) if agent_url: - fmt = run_async_in_sync_context(registry.get_format(str(agent_url), format_id)) + fmt: Format | None = run_async_in_sync_context(registry.get_format(str(agent_url), format_id)) if fmt: return fmt else: # Search all agents for this format - all_formats = run_async_in_sync_context(registry.list_all_formats(tenant_id=tenant_id)) + all_formats: list[Format] = run_async_in_sync_context(registry.list_all_formats(tenant_id=tenant_id)) for fmt in all_formats: discovered_format_ref = fmt.format_id discovered_format_id = getattr(discovered_format_ref, "id", discovered_format_ref) @@ -352,7 +352,9 @@ def list_available_formats_with_errors( # Get formats from all agents (default + tenant-specific) try: - result = run_async_in_sync_context(registry.list_all_formats_with_errors(tenant_id=tenant_id)) + result: FormatFetchResult | list[Format] = run_async_in_sync_context( + registry.list_all_formats_with_errors(tenant_id=tenant_id) + ) if isinstance(result, list): result = FormatFetchResult(formats=result, errors=[]) except Exception as e: diff --git a/src/core/helpers/context_helpers.py b/src/core/helpers/context_helpers.py index 408cc9f189..df46c46c62 100644 --- a/src/core/helpers/context_helpers.py +++ b/src/core/helpers/context_helpers.py @@ -64,7 +64,8 @@ def ensure_tenant_context(identity: ResolvedIdentity | None = None) -> dict[str, return loaded # DB lookup failed — use identity.tenant as fallback if identity and identity.tenant and isinstance(identity.tenant, dict) and "tenant_id" in identity.tenant: - set_current_tenant(identity.tenant) - return identity.tenant + fallback_tenant: dict[str, Any] = identity.tenant + set_current_tenant(fallback_tenant) + return fallback_tenant raise AdCPAuthenticationError("No tenant context available") diff --git a/src/core/http_utils.py b/src/core/http_utils.py index 4598f23a0a..e5e746d1ce 100644 --- a/src/core/http_utils.py +++ b/src/core/http_utils.py @@ -23,5 +23,6 @@ def get_header_case_insensitive(headers: Mapping[str, Any], header_name: str) -> header_name_lower = header_name.lower() for key, value in headers.items(): if key.lower() == header_name_lower: - return value + header_value: str | None = value + return header_value return None diff --git a/src/core/request_compat.py b/src/core/request_compat.py index 1f7e3e81f5..bf65271e9b 100644 --- a/src/core/request_compat.py +++ b/src/core/request_compat.py @@ -259,7 +259,8 @@ def _resolve_ref(schema: dict[str, Any], defs: dict[str, Any]) -> dict[str, Any] if len(parts) == 2: def_name = parts[1] if def_name in defs: - return defs[def_name] + resolved: dict[str, Any] = defs[def_name] + return resolved return schema diff --git a/src/core/strategy.py b/src/core/strategy.py index 37fb335cf6..47e84e7f0b 100644 --- a/src/core/strategy.py +++ b/src/core/strategy.py @@ -261,15 +261,18 @@ def should_force_error(self, error_type: str) -> bool: """Check if this strategy should force a specific error.""" if not self.is_simulation: return False - return self.get_config_value(f"force_{error_type}", False) + forced: bool = self.get_config_value(f"force_{error_type}", False) + return forced def get_pacing_multiplier(self) -> float: """Get pacing rate multiplier.""" - return self.get_config_value("pacing_rate", 1.0) + multiplier: float = self.get_config_value("pacing_rate", 1.0) + return multiplier def get_bid_adjustment(self) -> float: """Get bid adjustment multiplier.""" - return self.get_config_value("bid_adjustment", 1.0) + adjustment: float = self.get_config_value("bid_adjustment", 1.0) + return adjustment class SimulationContext: diff --git a/src/core/tenant_context.py b/src/core/tenant_context.py index 06385560d8..5eda211427 100644 --- a/src/core/tenant_context.py +++ b/src/core/tenant_context.py @@ -168,6 +168,9 @@ class LazyTenantContext: __slots__ = ("_tenant_id", "_resolved") + _tenant_id: str + _resolved: TenantContext | None + def __init__(self, tenant_id: str) -> None: object.__setattr__(self, "_tenant_id", tenant_id) object.__setattr__(self, "_resolved", None) diff --git a/src/core/tools/media_buy_create.py b/src/core/tools/media_buy_create.py index 986f9dc8cd..00deda52eb 100644 --- a/src/core/tools/media_buy_create.py +++ b/src/core/tools/media_buy_create.py @@ -2219,16 +2219,18 @@ def _derive_single_request_currency(package_pricing_info_by_index: dict[int, dic def _derive_legacy_request_currency(req: CreateMediaBuyRequest) -> str | None: """Best-effort currency extraction for deprecated request shapes.""" - legacy_currency = getattr(req, "currency", None) + legacy_currency: str | None = getattr(req, "currency", None) if legacy_currency: return legacy_currency legacy_budget = getattr(req, "budget", None) if legacy_budget and hasattr(legacy_budget, "currency"): - return legacy_budget.currency + budget_currency: str | None = legacy_budget.currency + return budget_currency if req.packages and req.packages[0].budget and hasattr(req.packages[0].budget, "currency"): - return req.packages[0].budget.currency + package_currency: str | None = req.packages[0].budget.currency + return package_currency return None diff --git a/src/core/tools/signals.py b/src/core/tools/signals.py index ac7bab2475..26a4ee823b 100644 --- a/src/core/tools/signals.py +++ b/src/core/tools/signals.py @@ -184,7 +184,8 @@ def _load_tenant_signals( def _dump_model(value: Any) -> dict[str, Any]: if hasattr(value, "model_dump"): - return value.model_dump(mode="json", exclude_none=True) + dumped: dict[str, Any] = value.model_dump(mode="json", exclude_none=True) + return dumped if isinstance(value, dict): return value return {} diff --git a/src/core/tools/workflow_serialization.py b/src/core/tools/workflow_serialization.py index cc63fe628a..3a5c720b78 100644 --- a/src/core/tools/workflow_serialization.py +++ b/src/core/tools/workflow_serialization.py @@ -12,4 +12,5 @@ def serialize_for_workflow_step(model: Any) -> dict[str, Any]: dump in one module-level helper keeps ``_impl`` functions free of direct serialization calls and centralizes the JSON mode. """ - return model.model_dump(mode="json") + payload: dict[str, Any] = model.model_dump(mode="json") + return payload diff --git a/src/core/utils/naming.py b/src/core/utils/naming.py index 31a3ef995a..7396cffba5 100644 --- a/src/core/utils/naming.py +++ b/src/core/utils/naming.py @@ -51,7 +51,8 @@ def _extract_brand_name(request) -> str | None: brand = request.brand if hasattr(brand, "domain"): - return brand.domain + domain: str | None = brand.domain + return domain if isinstance(brand, dict): return brand.get("domain") return None @@ -158,7 +159,7 @@ def generate_auto_name( # Run async agent — handle both sync and async calling contexts from src.core.validation_helpers import run_async_in_sync_context - generated_name = run_async_in_sync_context( + generated_name: str = run_async_in_sync_context( generate_name_async( agent=agent, campaign_name=None, # Not in AdCP spec diff --git a/src/core/version.py b/src/core/version.py index b9b5878692..21a791383a 100644 --- a/src/core/version.py +++ b/src/core/version.py @@ -35,7 +35,8 @@ def get_version() -> str: if pyproject_path.exists(): with open(pyproject_path, "rb") as f: data = tomllib.load(f) - return data.get("project", {}).get("version", "0.0.0") + version_str: str = data.get("project", {}).get("version", "0.0.0") + return version_str except (FileNotFoundError, tomllib.TOMLDecodeError, KeyError) as e: logger.debug("Failed to read version from pyproject.toml: %s", e) diff --git a/src/landing/landing_page.py b/src/landing/landing_page.py index 693412c28e..00142648cd 100644 --- a/src/landing/landing_page.py +++ b/src/landing/landing_page.py @@ -78,7 +78,8 @@ def _extract_tenant_subdomain(tenant: dict, virtual_host: str | None = None) -> # Fallback to tenant subdomain field if tenant.get("subdomain"): - return tenant["subdomain"] + tenant_subdomain: str = tenant["subdomain"] + return tenant_subdomain # Fallback to tenant_id return tenant.get("tenant_id") diff --git a/src/services/background_sync_service.py b/src/services/background_sync_service.py index 1b2df87e56..a565926fda 100644 --- a/src/services/background_sync_service.py +++ b/src/services/background_sync_service.py @@ -182,7 +182,7 @@ def start_inventory_sync_background( f"start_inventory_sync_background called with pending_sync_id=" f"{pending_sync_id!r} but no SyncJob row matches" ) - sync_id = pending_row.sync_id + sync_id: str = pending_row.sync_id pending_row.status = "running" # Restamp ``started_at`` so the value reflects when the worker # actually picked up the row, not when /refresh queued it. diff --git a/src/services/dynamic_products.py b/src/services/dynamic_products.py index 5b26665a50..32f4e0feda 100644 --- a/src/services/dynamic_products.py +++ b/src/services/dynamic_products.py @@ -218,7 +218,7 @@ def extract_activation_key(signal: dict, our_agent_url: str | None = None) -> di if destination.get("agent_url") == our_agent_url: # Found our deployment if deployment.get("is_live") and deployment.get("activation_key"): - activation_key = deployment["activation_key"] + activation_key: dict = deployment["activation_key"] # Validate activation key has required fields key_type = activation_key.get("type") @@ -458,7 +458,7 @@ def customize_description( # Default pattern: append signal description to product description if not template_description: # Generate description from signal if template has none - signal_desc = signal.get("description", "") + signal_desc: str = signal.get("description", "") if signal_desc: return signal_desc return None diff --git a/src/services/protocol_change_webhooks.py b/src/services/protocol_change_webhooks.py index 641034398e..dd424ed99b 100644 --- a/src/services/protocol_change_webhooks.py +++ b/src/services/protocol_change_webhooks.py @@ -445,7 +445,7 @@ def _build_wholesale_feed_event( applies_to=applies_to, ) - return WholesaleFeedEvent.model_validate( + event: dict[str, Any] = WholesaleFeedEvent.model_validate( { "event_id": event_id, "event_type": event_type, @@ -455,6 +455,7 @@ def _build_wholesale_feed_event( "payload": payload, } ).model_dump(mode="json", exclude_none=True) + return event def _build_bulk_change_event( @@ -467,7 +468,7 @@ def _build_bulk_change_event( applies_to: dict[str, Any], ) -> dict[str, Any]: affected_entity_type = "signal" if object_type == "signal" else "product" - return WholesaleFeedEvent.model_validate( + event: dict[str, Any] = WholesaleFeedEvent.model_validate( { "event_id": event_id, "event_type": "wholesale_feed.bulk_change", @@ -483,6 +484,7 @@ def _build_bulk_change_event( }, } ).model_dump(mode="json", exclude_none=True) + return event def _catalog_notification_type(object_type: str, action: str) -> str: diff --git a/src/services/protocol_webhook_service.py b/src/services/protocol_webhook_service.py index 8d32f59664..ab84928db5 100644 --- a/src/services/protocol_webhook_service.py +++ b/src/services/protocol_webhook_service.py @@ -85,8 +85,8 @@ class ProtocolWebhookService: - None: No authentication """ - def __init__(self): - self._session = requests.Session() + def __init__(self) -> None: + self._session: requests.Session = requests.Session() async def send_notification( self, diff --git a/src/services/push_notification_registration.py b/src/services/push_notification_registration.py index 43eb1f4ca0..1b01097418 100644 --- a/src/services/push_notification_registration.py +++ b/src/services/push_notification_registration.py @@ -197,7 +197,8 @@ def _config_to_dict(config: Any) -> dict[str, Any] | None: if isinstance(config, dict): return config if hasattr(config, "model_dump"): - return config.model_dump(mode="json", exclude_none=True) + dumped: dict[str, Any] = config.model_dump(mode="json", exclude_none=True) + return dumped return dict(config) diff --git a/src/services/webhook_delivery_service.py b/src/services/webhook_delivery_service.py index d48564bad5..b7cd179a23 100644 --- a/src/services/webhook_delivery_service.py +++ b/src/services/webhook_delivery_service.py @@ -140,7 +140,7 @@ def __init__(self, max_size: int = 1000): max_size: Maximum number of webhooks in queue """ self.max_size = max_size - self.queue: deque = deque(maxlen=max_size) + self.queue: deque[dict[str, Any]] = deque(maxlen=max_size) self._lock = threading.Lock() self._dropped_count = 0 From 31668d16350e4d4d3c6997c8162d463800804704 Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 16 Jul 2026 20:15:29 +0600 Subject: [PATCH 10/17] refactor: decompose activity formatter and share relative-time helper format_activity_from_audit_log (cyclomatic complexity 30) is now a small orchestrator over focused helpers: _classify_activity, _parse_audit_details, _build_detail_sections, _quoted_excerpt. Behavior verified byte-identical across 90 golden input/output cases (every operation type x timestamp bucket, naive and aware). The extraction exposed the same relative-time bucketing copied in three modules; all three now delegate to core.utils.time_format.relative_time_ago. One deliberate improvement: activity_feed previously returned "Unknown" for naive datetimes (a TypeError swallowed by its catch-all); the shared helper treats naive as UTC like the other two copies always did. Co-Authored-By: Claude Fable 5 --- src/admin/blueprints/activity_stream.py | 199 +++++++++--------- .../services/business_activity_service.py | 15 +- src/core/utils/time_format.py | 22 ++ src/services/activity_feed.py | 14 +- 4 files changed, 122 insertions(+), 128 deletions(-) create mode 100644 src/core/utils/time_format.py diff --git a/src/admin/blueprints/activity_stream.py b/src/admin/blueprints/activity_stream.py index 3287f9c87d..d5fad9e2e6 100644 --- a/src/admin/blueprints/activity_stream.py +++ b/src/admin/blueprints/activity_stream.py @@ -12,6 +12,7 @@ from src.admin.utils import require_tenant_access from src.core.database.database_session import get_db_session from src.core.database.models import AuditLog +from src.core.utils.time_format import relative_time_ago logger = logging.getLogger(__name__) @@ -24,92 +25,87 @@ connection_timestamps: dict[str, list[float]] = defaultdict(list) -def format_activity_from_audit_log(audit_log: AuditLog) -> dict: - """Convert AuditLog database record to activity feed format with rich details.""" - # Parse operation to extract method name - operation_parts = audit_log.operation.split(".", 1) - adapter_name = operation_parts[0] if len(operation_parts) > 1 else "system" - method = operation_parts[1] if len(operation_parts) > 1 else audit_log.operation +def _classify_activity(method: str, success: bool) -> str: + """Map an operation's method name (and outcome) to an activity-feed type.""" + method_lower = method.lower() + if "media_buy" in method_lower: + return "media-buy" + if "creative" in method_lower: + return "creative" + if "error" in method_lower or not success: + return "error" + if "get_products" in method_lower: + return "product-query" + if "human" in method_lower or "approval" in method_lower: + return "human-task" + return "api-call" + + +def _parse_audit_details(audit_log: AuditLog) -> dict: + """Return audit_log.details as a dict (JSONType gives a dict; legacy rows may hold a JSON string).""" + if not audit_log.details: + return {} + if isinstance(audit_log.details, dict): + return audit_log.details + try: + return json.loads(audit_log.details) + except (json.JSONDecodeError, TypeError): + return {} - # Determine activity type based on operation - if "media_buy" in method.lower(): - activity_type = "media-buy" - elif "creative" in method.lower(): - activity_type = "creative" - elif "error" in method.lower() or not audit_log.success: - activity_type = "error" - elif "get_products" in method.lower(): - activity_type = "product-query" - elif "human" in method.lower() or "approval" in method.lower(): - activity_type = "human-task" - else: - activity_type = "api-call" - # Build rich activity details based on operation type - details = {} - full_details = {} - action_required = False +def _quoted_excerpt(label: str, text: str, max_len: int) -> str: + """Render `label: "text"` truncating the text with an ellipsis beyond max_len.""" + if len(text) > max_len: + return f'{label}: "{text[:max_len]}..."' + return f'{label}: "{text}"' - # Parse the details JSON if available - parsed_details = {} - if audit_log.details: - try: - # details is already a dict from JSONType, not a string - if isinstance(audit_log.details, dict): - parsed_details = audit_log.details - else: - parsed_details = json.loads(audit_log.details) - except (json.JSONDecodeError, TypeError): - parsed_details = {} - - # Format based on operation type - if "get_products" in method.lower(): - details["primary"] = f"Found {parsed_details.get('product_count', 0)} products" - if parsed_details.get("brief"): - details["secondary"] = ( - f'Brief: "{parsed_details["brief"][:50]}..."' - if len(parsed_details.get("brief", "")) > 50 - else f'Brief: "{parsed_details.get("brief")}"' - ) - if parsed_details.get("products"): - full_details["products"] = parsed_details["products"] - full_details["promoted"] = parsed_details.get("promoted_product", "No specific promotion") - - elif "create_media_buy" in method.lower(): - if parsed_details.get("budget"): - details["primary"] = f"Budget: ${parsed_details['budget']:,.0f}" - if parsed_details.get("duration_days"): - details["secondary"] = f"Duration: {parsed_details['duration_days']} days" - if parsed_details.get("targeting"): - full_details["targeting"] = parsed_details["targeting"] - full_details["media_buy_id"] = parsed_details.get("media_buy_id", "N/A") - - elif "upload_creative" in method.lower(): - details["primary"] = f"Format: {parsed_details.get('format', 'Unknown')}" - if parsed_details.get("file_size"): - details["secondary"] = f"Size: {parsed_details['file_size']}" - full_details["creative_id"] = parsed_details.get("creative_id", "N/A") - full_details["status"] = parsed_details.get("status", "pending") - - elif "human" in method.lower() or "approval" in method.lower(): + +def _build_detail_sections( + audit_log: AuditLog, method: str, adapter_name: str, parsed: dict +) -> tuple[dict, dict, bool]: + """Build (details, full_details, action_required) for one audit entry by operation type.""" + details: dict = {} + full_details: dict = {} + action_required = False + method_lower = method.lower() + + if "get_products" in method_lower: + details["primary"] = f"Found {parsed.get('product_count', 0)} products" + if parsed.get("brief"): + details["secondary"] = _quoted_excerpt("Brief", parsed["brief"], 50) + if parsed.get("products"): + full_details["products"] = parsed["products"] + full_details["promoted"] = parsed.get("promoted_product", "No specific promotion") + + elif "create_media_buy" in method_lower: + if parsed.get("budget"): + details["primary"] = f"Budget: ${parsed['budget']:,.0f}" + if parsed.get("duration_days"): + details["secondary"] = f"Duration: {parsed['duration_days']} days" + if parsed.get("targeting"): + full_details["targeting"] = parsed["targeting"] + full_details["media_buy_id"] = parsed.get("media_buy_id", "N/A") + + elif "upload_creative" in method_lower: + details["primary"] = f"Format: {parsed.get('format', 'Unknown')}" + if parsed.get("file_size"): + details["secondary"] = f"Size: {parsed['file_size']}" + full_details["creative_id"] = parsed.get("creative_id", "N/A") + full_details["status"] = parsed.get("status", "pending") + + elif "human" in method_lower or "approval" in method_lower: details["primary"] = "⚠️ Human approval required" - details["secondary"] = parsed_details.get("task_type", "Review required") - full_details["task_id"] = parsed_details.get("task_id") - full_details["task_details"] = parsed_details.get("details", {}) + details["secondary"] = parsed.get("task_type", "Review required") + full_details["task_id"] = parsed.get("task_id") + full_details["task_details"] = parsed.get("details", {}) action_required = True elif adapter_name == "A2A" or audit_log.operation.startswith("A2A."): - # Handle A2A operations with rich details details["primary"] = "🔄 A2A Protocol" - if parsed_details.get("query"): - details["secondary"] = ( - f'Query: "{parsed_details["query"][:60]}..."' - if len(parsed_details.get("query", "")) > 60 - else f'Query: "{parsed_details.get("query")}"' - ) - - # Include all A2A details for expansion - full_details = parsed_details.copy() # Show all A2A details when expanded + if parsed.get("query"): + details["secondary"] = _quoted_excerpt("Query", parsed["query"], 60) + # Show all A2A details when expanded + full_details = parsed.copy() elif not audit_log.success: details["primary"] = "❌ Failed" @@ -118,46 +114,41 @@ def format_activity_from_audit_log(audit_log: AuditLog) -> dict: audit_log.error_message[:75] + "..." if len(audit_log.error_message) > 75 else audit_log.error_message ) full_details["error_details"] = audit_log.error_message + else: - # Default success case details["primary"] = "✅ Success" - if parsed_details: - # Show first interesting field from details - for key in ["message", "result", "count", "status"]: - if key in parsed_details: - details["secondary"] = str(parsed_details[key])[:75] - break + # Show first interesting field from details + for key in ["message", "result", "count", "status"]: + if key in parsed: + details["secondary"] = str(parsed[key])[:75] + break + + return details, full_details, action_required - # Calculate relative time + +def format_activity_from_audit_log(audit_log: AuditLog) -> dict: + """Convert AuditLog database record to activity feed format with rich details.""" from typing import cast - now = datetime.now(UTC) + # Operation is "."; bare operations count as system methods + operation_parts = audit_log.operation.split(".", 1) + adapter_name = operation_parts[0] if len(operation_parts) > 1 else "system" + method = operation_parts[1] if len(operation_parts) > 1 else audit_log.operation + + parsed_details = _parse_audit_details(audit_log) + details, full_details, action_required = _build_detail_sections(audit_log, method, adapter_name, parsed_details) + timestamp = cast(datetime, audit_log.timestamp) - if timestamp.tzinfo is None: - # Handle naive datetime (assume UTC) - audit_timestamp = timestamp.replace(tzinfo=UTC) - else: - audit_timestamp = timestamp - - delta = now - audit_timestamp - if delta.days > 0: - time_relative = f"{delta.days}d ago" - elif delta.seconds > 3600: - time_relative = f"{delta.seconds // 3600}h ago" - elif delta.seconds > 60: - time_relative = f"{delta.seconds // 60}m ago" - else: - time_relative = "Just now" return { "id": audit_log.log_id, - "type": activity_type, + "type": _classify_activity(method, audit_log.success), "principal_name": audit_log.principal_name or "System", "action": f"Called {method}", "details": details, "full_details": full_details, "timestamp": timestamp.isoformat(), - "time_relative": time_relative, + "time_relative": relative_time_ago(timestamp), "action_required": action_required, "operation": audit_log.operation, "success": audit_log.success, diff --git a/src/admin/services/business_activity_service.py b/src/admin/services/business_activity_service.py index 19fe2c19f2..f4f3f2bf88 100644 --- a/src/admin/services/business_activity_service.py +++ b/src/admin/services/business_activity_service.py @@ -18,6 +18,7 @@ from src.core.database.database_session import get_db_session from src.core.database.models import AuditLog, Principal +from src.core.utils.time_format import relative_time_ago logger = logging.getLogger(__name__) @@ -253,18 +254,6 @@ def get_business_activities(tenant_id: str, limit: int = 50) -> list[dict]: # Add relative time formatting now = datetime.now(UTC) for activity in activities[:limit]: - timestamp = cast(datetime, activity["timestamp"]) - if timestamp.tzinfo is None: - timestamp = timestamp.replace(tzinfo=UTC) - - delta = now - timestamp - if delta.days > 0: - activity["time_relative"] = f"{delta.days}d ago" - elif delta.seconds > 3600: - activity["time_relative"] = f"{delta.seconds // 3600}h ago" - elif delta.seconds > 60: - activity["time_relative"] = f"{delta.seconds // 60}m ago" - else: - activity["time_relative"] = "Just now" + activity["time_relative"] = relative_time_ago(cast(datetime, activity["timestamp"]), now) return activities[:limit] diff --git a/src/core/utils/time_format.py b/src/core/utils/time_format.py new file mode 100644 index 0000000000..c1f254ff0b --- /dev/null +++ b/src/core/utils/time_format.py @@ -0,0 +1,22 @@ +"""Shared human-relative time formatting.""" + +from datetime import UTC, datetime + + +def relative_time_ago(timestamp: datetime, now: datetime | None = None) -> str: + """Humanize how long ago ``timestamp`` was: "3d ago", "2h ago", "5m ago", "Just now". + + Naive timestamps are assumed to be UTC. Pass ``now`` to reuse one reference + instant across a batch of rows. + """ + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=UTC) + reference = now if now is not None else datetime.now(UTC) + delta = reference - timestamp + if delta.days > 0: + return f"{delta.days}d ago" + if delta.seconds > 3600: + return f"{delta.seconds // 3600}h ago" + if delta.seconds > 60: + return f"{delta.seconds // 60}m ago" + return "Just now" diff --git a/src/services/activity_feed.py b/src/services/activity_feed.py index dfda44d66a..2c1d1674e9 100644 --- a/src/services/activity_feed.py +++ b/src/services/activity_feed.py @@ -8,6 +8,8 @@ from datetime import UTC, datetime from typing import Any +from src.core.utils.time_format import relative_time_ago + logger = logging.getLogger(__name__) @@ -188,17 +190,7 @@ def _get_relative_time(self, timestamp: str) -> str: dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) else: dt = timestamp - - now = datetime.now(UTC) - delta = now - dt - - if delta.days > 0: - return f"{delta.days}d ago" - if delta.seconds > 3600: - return f"{delta.seconds // 3600}h ago" - if delta.seconds > 60: - return f"{delta.seconds // 60}m ago" - return "Just now" + return relative_time_ago(dt) except Exception: logger.debug("Failed to format time_ago", exc_info=True) return "Unknown" From 4067ce81867d74e08d7dcc962e52c0e591bdf51f Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 16 Jul 2026 20:16:49 +0600 Subject: [PATCH 11/17] docs: extend session report with mypy strictness and dedup results Co-Authored-By: Claude Fable 5 --- .../codebase-improvement-2026-07.md | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/development/codebase-improvement-2026-07.md b/docs/development/codebase-improvement-2026-07.md index bf315af36a..9f9b99123b 100644 --- a/docs/development/codebase-improvement-2026-07.md +++ b/docs/development/codebase-improvement-2026-07.md @@ -1,6 +1,28 @@ # Codebase Improvement Session — 2026-07-16 -Branch: `feature/refactor-all` (base `26fa253bf`). Eight commits, `10cff0b0a..f300cc2cd`. +Branch: `feature/refactor-all` (base `26fa253bf`). Ten commits, `10cff0b0a..31668d163`. + +## Continuation (same day): mypy strictness + refactor exemplar + +- **`warn_return_any = True` is now enforced** (mypy.ini roadmap step 1 executed): all 86 + `no-any-return` sites fixed across 43 files with typed intermediates/casts — no behavior + change, verified by a cold `--no-incremental` mypy run (398 files green) and byte-identical + unit-suite totals (307/5962/24). One wrong annotation corrected + (`json_type.process_bind_param` omitted its deliberate `BaseModel` passthrough). + Remaining roadmap: `check_untyped_defs` (~203 errors), `disallow_incomplete_defs` (~273). +- **Complexity exemplar**: `format_activity_from_audit_log` (C=30) decomposed into + `_classify_activity` / `_parse_audit_details` / `_build_detail_sections` / shared + `relative_time_ago` — behavior locked byte-identical across 90 golden cases. The extraction + exposed the relative-time bucketing copied in three modules + (activity_stream, activity_feed, business_activity_service); all three now delegate to + `src/core/utils/time_format.relative_time_ago`. Duplication ratchet back to 18 blocks + via real dedup, not baseline growth. +- **New latent-bug suspects logged by the sweep** (annotation-only fixes applied, semantics + untouched — each needs a small deliberate fix): Broadstreet `delete_*` methods return + `None` on empty 204 bodies where `dict` is declared; Broadstreet `create_placement` skips + the response-envelope unwrap all sibling methods do; mock adapter `_is_simulation` returns + `None` instead of `False`; `from adcp import WholesaleFeedEvent` types as `Any` despite + the adcp mypy plugin (upstream re-export gap). ## Verified end state (honest exit codes, no caches) From b4f8fb143362755bc9bc5150a9e76079b653eca1 Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 16 Jul 2026 23:22:45 +0600 Subject: [PATCH 12/17] fix: correct Broadstreet empty-body handling and mock simulation flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit delete_campaign/delete_advertisement/delete_zone declared dict returns but DELETE endpoints answer with empty 204 bodies, so callers doing .get() on the result would crash on None — normalize to {}. create_placement now unwraps the response envelope like every sibling create_* method. Mock adapter _is_simulation returned None (falsy object) instead of False when no strategy context is set. Co-Authored-By: Claude Fable 5 --- src/adapters/broadstreet/client.py | 22 ++++++++++++---------- src/adapters/mock_ad_server.py | 2 +- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/adapters/broadstreet/client.py b/src/adapters/broadstreet/client.py index 6c58850f9a..26c0c17598 100644 --- a/src/adapters/broadstreet/client.py +++ b/src/adapters/broadstreet/client.py @@ -242,9 +242,9 @@ def create_campaign( return result.get("campaign", result) if result else {} def delete_campaign(self, advertiser_id: str, campaign_id: str) -> dict[str, Any]: - """Delete a campaign.""" - result: dict[str, Any] = self.delete( - f"/networks/{self.network_id}/advertisers/{advertiser_id}/campaigns/{campaign_id}" + """Delete a campaign. DELETE endpoints may return an empty body.""" + result: dict[str, Any] = ( + self.delete(f"/networks/{self.network_id}/advertisers/{advertiser_id}/campaigns/{campaign_id}") or {} ) return result @@ -326,9 +326,10 @@ def set_advertisement_source( return result.get("advertisement", result) if result else {} def delete_advertisement(self, advertiser_id: str, advertisement_id: str) -> dict[str, Any]: - """Delete an advertisement.""" - result: dict[str, Any] = self.delete( - f"/networks/{self.network_id}/advertisers/{advertiser_id}/advertisements/{advertisement_id}" + """Delete an advertisement. DELETE endpoints may return an empty body.""" + result: dict[str, Any] = ( + self.delete(f"/networks/{self.network_id}/advertisers/{advertiser_id}/advertisements/{advertisement_id}") + or {} ) return result @@ -388,11 +389,12 @@ def create_placement( "zone_id": zone_id, "advertisement_id": advertisement_id, } - result: dict[str, Any] = self.post( + result: dict[str, Any] | None = self.post( f"/networks/{self.network_id}/advertisers/{advertiser_id}/campaigns/{campaign_id}/placements", data, ) - return result + # Unwrap the response envelope like every sibling create_* method + return result.get("placement", result) if result else {} # ========================================================================= # Zone Operations @@ -428,6 +430,6 @@ def create_zone( return result.get("zone", result) if result else {} def delete_zone(self, zone_id: str) -> dict[str, Any]: - """Delete a zone.""" - result: dict[str, Any] = self.delete(f"/networks/{self.network_id}/zones/{zone_id}") + """Delete a zone. DELETE endpoints may return an empty body.""" + result: dict[str, Any] = self.delete(f"/networks/{self.network_id}/zones/{zone_id}") or {} return result diff --git a/src/adapters/mock_ad_server.py b/src/adapters/mock_ad_server.py index c1a531cf60..218616bf98 100644 --- a/src/adapters/mock_ad_server.py +++ b/src/adapters/mock_ad_server.py @@ -101,7 +101,7 @@ def __init__(self, config, principal, dry_run=False, creative_engine=None, tenan def _is_simulation(self) -> bool: """Check if we're running in simulation mode.""" - return ( + return bool( self.strategy_context and hasattr(self.strategy_context, "is_simulation") and hasattr(self.strategy_context, "strategy_id") From 5e971b5ad5cb556e147b98be8f75dbfeec39351c Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 16 Jul 2026 23:22:45 +0600 Subject: [PATCH 13/17] refactor: decompose auth resolver and admin form parsers (SRP/KISS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three complexity hotspots become small orchestrators over focused module-level helpers, behavior locked before/after: - get_principal_from_context (C=41 -> under 20): golden-mastered with 28 synthetic inputs covering every branch, external lookups patched at their real import sites, and the chronological dependency call log asserted — outputs, exceptions, and lookup ORDER byte-identical. - parse_form_data_to_policy_updates (C=43) and parse_pricing_options_from_form (C=31): golden-locked over branch-covering synthetic form inputs — both diffs empty. Full unit suite unchanged: 307/5962/24 with a per-test set-diff of zero new failures. Duplication ratchet tightened 18 -> 17 blocks. Co-Authored-By: Claude Fable 5 --- .duplication-baseline | 1 - src/admin/blueprints/products.py | 229 ++++++++++++---------- src/admin/blueprints/settings.py | 63 +++++-- src/core/auth.py | 314 ++++++++++++++++++------------- 4 files changed, 350 insertions(+), 257 deletions(-) diff --git a/.duplication-baseline b/.duplication-baseline index 37c41435a7..fa178eeb3d 100644 --- a/.duplication-baseline +++ b/.duplication-baseline @@ -12,7 +12,6 @@ "src.admin.blueprints.products,src.core.creative_agent_registry", "src.admin.blueprints.products,src.core.database.product_pricing", "src.admin.blueprints.products,src.core.format_resolver", - "src.core.auth,src.core.testing_hooks", "src.core.creative_agent_registry,src.core.format_resolver", "src.core.creative_agent_registry,src.core.signals_agent_registry", "src.core.schemas._base,src.core.schemas.account", diff --git a/src/admin/blueprints/products.py b/src/admin/blueprints/products.py index 4bab3dbedb..6c64fad9b6 100644 --- a/src/admin/blueprints/products.py +++ b/src/admin/blueprints/products.py @@ -296,16 +296,8 @@ def get_creative_formats( return catalog.formats -def parse_pricing_options_from_form(form_data: dict) -> list[dict]: - """Parse pricing options from form data (AdCP PR #88). - - Form data uses indexed fields: pricing_model_0, pricing_model_1, etc. - Indices may be non-contiguous if user removed and re-added pricing options. - - Returns list of pricing option dicts ready for database insertion. - """ - pricing_options = [] - +def _collect_pricing_indices(form_data: dict) -> list[int]: + """Return the sorted pricing option indices found in form keys.""" # Find all pricing option indices by scanning form keys # This handles non-contiguous indices (e.g., 0 removed, only 1 exists) indices = set() @@ -316,110 +308,140 @@ def parse_pricing_options_from_form(form_data: dict) -> list[dict]: indices.add(idx) except ValueError: pass + return sorted(indices) + + +def _resolve_pricing_model(pricing_model_raw: str) -> tuple[str, bool]: + """Map a combined form pricing-model value to (pricing_model, is_fixed).""" + # Parse pricing model and is_fixed from combined value + # Guaranteed (fixed): cpm_fixed, flat_rate + # Non-guaranteed (auction): cpm_auction, vcpm, cpc + if pricing_model_raw == "cpm_fixed": + return "cpm", True + if pricing_model_raw == "cpm_auction": + return "cpm", False + if pricing_model_raw == "flat_rate": + return "flat_rate", True + if pricing_model_raw == "vcpm": + return "vcpm", False # vCPM is always auction-based + if pricing_model_raw == "cpc": + return "cpc", False # CPC is always auction-based + # Fallback for any other models (shouldn't happen with current UI) + return pricing_model_raw, True + + +def _parse_pricing_rate(form_data: dict, index: int, is_fixed: bool) -> float | None: + """Parse the rate field, enforcing that fixed pricing always has one.""" + rate = None + rate_str = form_data.get(f"rate_{index}", "").strip() + if rate_str: + try: + rate = float(rate_str) + except ValueError as e: + raise ValueError(f"Invalid rate value for pricing option {index}") from e - # Process each found index in order - for index in sorted(indices): - pricing_model_raw = form_data.get(f"pricing_model_{index}") - if not pricing_model_raw: - continue + # Validate rate is required for fixed pricing + if is_fixed and rate is None: + raise ValueError(f"Rate is required for fixed pricing (pricing option {index})") - # Parse pricing model and is_fixed from combined value - # Guaranteed (fixed): cpm_fixed, flat_rate - # Non-guaranteed (auction): cpm_auction, vcpm, cpc - if pricing_model_raw == "cpm_fixed": - pricing_model = "cpm" - is_fixed = True - elif pricing_model_raw == "cpm_auction": - pricing_model = "cpm" - is_fixed = False - elif pricing_model_raw == "flat_rate": - pricing_model = "flat_rate" - is_fixed = True - elif pricing_model_raw == "vcpm": - pricing_model = "vcpm" - is_fixed = False # vCPM is always auction-based - elif pricing_model_raw == "cpc": - pricing_model = "cpc" - is_fixed = False # CPC is always auction-based - else: - # Fallback for any other models (shouldn't happen with current UI) - pricing_model = pricing_model_raw - is_fixed = True + return rate - # Parse basic fields - currency = form_data.get(f"currency_{index}", "USD") - # Parse rate (for fixed pricing) - rate = None - rate_str = form_data.get(f"rate_{index}", "").strip() - if rate_str: - try: - rate = float(rate_str) - except ValueError as e: - raise ValueError(f"Invalid rate value for pricing option {index}") from e - - # Validate rate is required for fixed pricing - if is_fixed and rate is None: - raise ValueError(f"Rate is required for fixed pricing (pricing option {index})") - - # Parse price_guidance (for auction pricing) - price_guidance = None - if not is_fixed: - # Floor price is required for auction - floor_str = form_data.get(f"floor_{index}", "").strip() - if not floor_str: - raise ValueError(f"Floor price is required for auction pricing (pricing option {index})") - try: - floor = float(floor_str) - price_guidance = {"floor": floor} +def _parse_price_guidance(form_data: dict, index: int, is_fixed: bool) -> dict | None: + """Parse auction price guidance (required floor plus optional percentiles).""" + if is_fixed: + return None - # Optional percentiles - for percentile in ["p25", "p50", "p75", "p90"]: - value_str = form_data.get(f"{percentile}_{index}", "").strip() - if value_str: - try: - price_guidance[percentile] = float(value_str) - except ValueError: - pass - except ValueError as e: - raise ValueError(f"Invalid floor price value for pricing option {index}") from e - - # Parse min_spend_per_package - min_spend = None - min_spend_str = form_data.get(f"min_spend_{index}", "").strip() - if min_spend_str: - try: - min_spend = float(min_spend_str) - except ValueError: - pass + # Floor price is required for auction + floor_str = form_data.get(f"floor_{index}", "").strip() + if not floor_str: + raise ValueError(f"Floor price is required for auction pricing (pricing option {index})") + try: + floor = float(floor_str) + price_guidance = {"floor": floor} - # Parse model-specific parameters - parameters = None - if pricing_model == "cpp": - # CPP parameters - demographic = form_data.get(f"demographic_{index}", "").strip() - min_points_str = form_data.get(f"min_points_{index}", "").strip() - if demographic or min_points_str: - parameters = {} - if demographic: - parameters["demographic"] = demographic - if min_points_str: - try: - parameters["min_points"] = float(min_points_str) - except ValueError: - pass + # Optional percentiles + for percentile in ["p25", "p50", "p75", "p90"]: + value_str = form_data.get(f"{percentile}_{index}", "").strip() + if value_str: + try: + price_guidance[percentile] = float(value_str) + except ValueError: + pass + except ValueError as e: + raise ValueError(f"Invalid floor price value for pricing option {index}") from e + + return price_guidance - elif pricing_model == "cpv": - # CPV parameters - view_threshold_str = form_data.get(f"view_threshold_{index}", "").strip() - if view_threshold_str: + +def _parse_min_spend(form_data: dict, index: int) -> float | None: + """Parse the optional min_spend field, ignoring invalid values.""" + min_spend = None + min_spend_str = form_data.get(f"min_spend_{index}", "").strip() + if min_spend_str: + try: + min_spend = float(min_spend_str) + except ValueError: + pass + return min_spend + + +def _parse_model_parameters(form_data: dict, index: int, pricing_model: str) -> dict | None: + """Parse model-specific parameters for cpp and cpv pricing models.""" + parameters = None + if pricing_model == "cpp": + # CPP parameters + demographic = form_data.get(f"demographic_{index}", "").strip() + min_points_str = form_data.get(f"min_points_{index}", "").strip() + if demographic or min_points_str: + parameters = {} + if demographic: + parameters["demographic"] = demographic + if min_points_str: try: - view_threshold = float(view_threshold_str) - if 0 <= view_threshold <= 1: - parameters = {"view_threshold": view_threshold} + parameters["min_points"] = float(min_points_str) except ValueError: pass + elif pricing_model == "cpv": + # CPV parameters + view_threshold_str = form_data.get(f"view_threshold_{index}", "").strip() + if view_threshold_str: + try: + view_threshold = float(view_threshold_str) + if 0 <= view_threshold <= 1: + parameters = {"view_threshold": view_threshold} + except ValueError: + pass + + return parameters + + +def parse_pricing_options_from_form(form_data: dict) -> list[dict]: + """Parse pricing options from form data (AdCP PR #88). + + Form data uses indexed fields: pricing_model_0, pricing_model_1, etc. + Indices may be non-contiguous if user removed and re-added pricing options. + + Returns list of pricing option dicts ready for database insertion. + """ + pricing_options = [] + + # Process each found index in order + for index in _collect_pricing_indices(form_data): + pricing_model_raw = form_data.get(f"pricing_model_{index}") + if not pricing_model_raw: + continue + + pricing_model, is_fixed = _resolve_pricing_model(pricing_model_raw) + + # Parse basic fields + currency = form_data.get(f"currency_{index}", "USD") + rate = _parse_pricing_rate(form_data, index, is_fixed) + price_guidance = _parse_price_guidance(form_data, index, is_fixed) + min_spend = _parse_min_spend(form_data, index) + parameters = _parse_model_parameters(form_data, index, pricing_model) + # Build pricing option dict pricing_option = { "pricing_model": pricing_model, @@ -432,7 +454,6 @@ def parse_pricing_options_from_form(form_data: dict) -> list[dict]: } pricing_options.append(pricing_option) - index += 1 return pricing_options diff --git a/src/admin/blueprints/settings.py b/src/admin/blueprints/settings.py index f92ba4c30d..105ad58544 100644 --- a/src/admin/blueprints/settings.py +++ b/src/admin/blueprints/settings.py @@ -1131,22 +1131,12 @@ def test_domain_access(tenant_id): return redirect(url_for("tenants.tenant_settings", tenant_id=tenant_id, section="access")) -def parse_form_data_to_policy_updates(form_data) -> dict[str, Any]: - """Parse Flask form data into PolicyService update format. - - Args: - form_data: Flask request.form or request.get_json() data - - Returns: - Dict suitable for PolicyService.update_policies() - """ +def _parse_currency_limits(form_data, updates: dict[str, Any]) -> None: + """Parse currency_limits[...] form fields into a currencies update.""" from decimal import Decimal from src.services.policy_service import CurrencyLimitData - updates: dict[str, Any] = {} - - # Parse currency limits currency_data: dict[str, dict[str, Any]] = {} for key in form_data.keys(): if key.startswith("currency_limits["): @@ -1176,7 +1166,9 @@ def parse_form_data_to_policy_updates(form_data) -> dict[str, Any]: for code, data in currency_data.items() ] - # Parse measurement providers + +def _parse_measurement_providers(form_data, updates: dict[str, Any]) -> None: + """Parse measurement provider form fields into a providers/default update.""" # Check if measurement providers section is present in the form # Hidden field _measurement_providers_section ensures validation runs even when all providers removed has_provider_section = "_measurement_providers_section" in form_data @@ -1200,14 +1192,18 @@ def parse_form_data_to_policy_updates(form_data) -> dict[str, Any]: # This ensures validation runs even when removing all providers updates["measurement_providers"] = {"providers": providers, "default": default_provider} - # Parse naming templates + +def _parse_naming_templates(form_data, updates: dict[str, Any]) -> None: + """Parse order/line-item naming template form fields.""" if "order_name_template" in form_data: updates["order_name_template"] = form_data.get("order_name_template", "").strip() if "line_item_name_template" in form_data: updates["line_item_name_template"] = form_data.get("line_item_name_template", "").strip() - # Parse approval settings + +def _parse_approval_settings(form_data, updates: dict[str, Any]) -> None: + """Parse approval mode, review criteria, and creative threshold form fields.""" if "approval_mode" in form_data: updates["approval_mode"] = form_data.get("approval_mode", "auto-approve") @@ -1226,7 +1222,9 @@ def parse_form_data_to_policy_updates(form_data) -> dict[str, Any]: except (ValueError, TypeError): pass - # Parse feature flags + +def _parse_feature_flags(form_data, updates: dict[str, Any]) -> None: + """Parse enable_axe_signals and brand_manifest_policy form fields.""" if "enable_axe_signals" in form_data: updates["enable_axe_signals"] = form_data.get("enable_axe_signals") in [True, "true", "on", 1, "1"] @@ -1241,7 +1239,9 @@ def parse_form_data_to_policy_updates(form_data) -> dict[str, Any]: if policy_value: updates["brand_manifest_policy"] = policy_value - # Parse AI policy + +def _parse_ai_policy(form_data, updates: dict[str, Any]) -> None: + """Parse AI creative-policy form fields into an ai_policy update.""" ai_policy_fields = [ "creative_auto_approve_threshold", "creative_auto_reject_threshold", @@ -1279,7 +1279,9 @@ def parse_form_data_to_policy_updates(form_data) -> dict[str, Any]: if ai_policy: updates["ai_policy"] = ai_policy - # Parse advertising policy + +def _parse_advertising_policy(form_data, updates: dict[str, Any]) -> None: + """Parse advertising policy toggle and prohibited-list form fields.""" advertising_policy_fields = [ "policy_check_enabled", "default_prohibited_categories", @@ -1316,11 +1318,34 @@ def parse_form_data_to_policy_updates(form_data) -> dict[str, Any]: if advertising_policy: updates["advertising_policy"] = advertising_policy - # Parse product ranking prompt + +def _parse_product_ranking_prompt(form_data, updates: dict[str, Any]) -> None: + """Parse the product_ranking_prompt form field (blank becomes None).""" if "product_ranking_prompt" in form_data: prompt_value = form_data.get("product_ranking_prompt", "").strip() updates["product_ranking_prompt"] = prompt_value if prompt_value else None + +def parse_form_data_to_policy_updates(form_data) -> dict[str, Any]: + """Parse Flask form data into PolicyService update format. + + Args: + form_data: Flask request.form or request.get_json() data + + Returns: + Dict suitable for PolicyService.update_policies() + """ + updates: dict[str, Any] = {} + + _parse_currency_limits(form_data, updates) + _parse_measurement_providers(form_data, updates) + _parse_naming_templates(form_data, updates) + _parse_approval_settings(form_data, updates) + _parse_feature_flags(form_data, updates) + _parse_ai_policy(form_data, updates) + _parse_advertising_policy(form_data, updates) + _parse_product_ranking_prompt(form_data, updates) + return updates diff --git a/src/core/auth.py b/src/core/auth.py index 4b1ef896d7..0be05bdb42 100644 --- a/src/core/auth.py +++ b/src/core/auth.py @@ -91,6 +91,68 @@ def get_principal_from_context( if isinstance(context, ToolContext): return (context.principal_id, {"tenant_id": context.tenant_id}) + headers = _extract_headers_from_context(context) + + # If still no headers dict available, return None + if not headers: + return (None, None) + + # ALWAYS resolve tenant from headers first (even without auth for public discovery endpoints) + requested_tenant_id, tenant_context = _resolve_tenant_from_headers(headers) + + # NOW check for auth token (after tenant resolution) + auth_token, auth_source = _extract_auth_token_from_headers(headers) + + if _VERBOSE_AUTH_LOG and auth_source: + logger.info("Auth token found via: %s", auth_source) + + if not auth_token: + # Embedded-mode buyer-protocol identity-from-headers path + # (docs/design/embedded-mode.md §2): when a tenant is provisioned with + # ``is_embedded=True`` and the deployment opts in via + # ``MANAGED_INSTANCE=true``, callers identify the acting principal via + # ``X-Principal-Id`` (and the descriptive ``X-Identity-*`` headers from + # the same propagation contract used by the admin UI proxy). No + # protocol-level token check — trust is established by the network + # layer (the salesagent binds to a private interface and accepts + # buyer-protocol traffic only from the configured host product proxy). + embedded_principal_id = _try_resolve_embedded_buyer_identity(headers, tenant_context, require_valid_token) + if embedded_principal_id is not None: + return (embedded_principal_id, tenant_context) + + logger.debug("No auth token found - OK for discovery endpoints") + return (None, tenant_context) + + # Validate token and get principal + # If requested_tenant_id is set: validate token belongs to that specific tenant + # If requested_tenant_id is None: do global lookup and set tenant context from token + if not requested_tenant_id: + # No tenant detected from headers - use global token lookup + # SECURITY NOTE: This is safe because get_principal_from_token() will: + # 1. Look up the token globally + # 2. Find which tenant it belongs to + # 3. Return (principal_id, tenant_dict) — caller sets context + # 4. Return principal_id only if token is valid for that tenant + logger.debug("Using global token lookup (finds tenant from token)") + + principal_id, token_tenant = get_principal_from_token(auth_token, requested_tenant_id) + + # If token was provided but invalid, raise an error (unless require_valid_token=False for discovery) + # This distinguishes between "no auth" (OK) and "bad auth" (error or warning) + if principal_id is None: + return _reject_or_ignore_invalid_token(requested_tenant_id, tenant_context, require_valid_token) + + # If tenant_context wasn't set by header detection, use tenant discovered from token + if not tenant_context and token_tenant: + tenant_context = token_tenant + + # Return both principal_id and tenant_context explicitly + # Caller MUST call set_current_tenant(tenant_context) in their async context + return (principal_id, tenant_context) + + +def _extract_headers_from_context(context: Context | None) -> dict | None: + """Get HTTP headers via get_http_headers(), falling back to context attributes for sync tools.""" # Get headers using the recommended FastMCP approach # NOTE: get_http_headers() works via context vars, so it can work even when context=None # This allows unauthenticated public discovery endpoints to detect tenant from headers @@ -115,101 +177,126 @@ def get_principal_from_context( elif hasattr(context, "_headers"): headers = context._headers - # If still no headers dict available, return None - if not headers: - return (None, None) + return headers - # Extract headers for tenant detection - host_header = _get_header_case_insensitive(headers, "host") - apx_host_header = _get_header_case_insensitive(headers, "apx-incoming-host") - tenant_header = _get_header_case_insensitive(headers, "x-adcp-tenant") +def _resolve_tenant_from_headers(headers: dict) -> tuple[str | None, dict | None]: + """Resolve the requested tenant from request headers, trying each detection method in priority order.""" if _VERBOSE_AUTH_LOG: logger.info( - "Tenant detection - Host: %s, Apx-Host: %s, x-adcp-tenant: %s", host_header, apx_host_header, tenant_header + "Tenant detection - Host: %s, Apx-Host: %s, x-adcp-tenant: %s", + _get_header_case_insensitive(headers, "host"), + _get_header_case_insensitive(headers, "apx-incoming-host"), + _get_header_case_insensitive(headers, "x-adcp-tenant"), ) - # ALWAYS resolve tenant from headers first (even without auth for public discovery endpoints) requested_tenant_id = None tenant_context = None detection_method = None + for resolver in ( + _tenant_from_host_header, # 1. Host header - virtual host FIRST, then subdomain + _tenant_from_adcp_tenant_header, # 2. x-adcp-tenant header (set by nginx for path-based routing) + _tenant_from_apx_host_header, # 3. Apx-Incoming-Host header (for Approximated.app virtual hosts) + _tenant_from_localhost_fallback, # 4. Fallback for localhost in development: use "default" tenant + ): + requested_tenant_id, tenant_context, detection_method = resolver(headers) + if requested_tenant_id: + break + + if _VERBOSE_AUTH_LOG: + if requested_tenant_id: + logger.info("Final tenant_id: %s (via %s)", requested_tenant_id, detection_method) + else: + logger.debug("No tenant detected from headers") + + return requested_tenant_id, tenant_context - # 1. Check host header - try virtual host FIRST, then fall back to subdomain - if not requested_tenant_id: - host = _get_header_case_insensitive(headers, "host") or "" - apx_host = _get_header_case_insensitive(headers, "apx-incoming-host") - # CRITICAL: Try virtual host lookup FIRST before extracting subdomain - # This prevents issues where a subdomain happens to match a virtual host - tenant_context = get_tenant_by_virtual_host(host) +def _tenant_from_host_header(headers: dict) -> tuple[str | None, dict | None, str | None]: + """Resolve tenant from the Host header — virtual host lookup first, then subdomain.""" + host = _get_header_case_insensitive(headers, "host") or "" + + # CRITICAL: Try virtual host lookup FIRST before extracting subdomain + # This prevents issues where a subdomain happens to match a virtual host + tenant_context = get_tenant_by_virtual_host(host) + if tenant_context: + requested_tenant_id = tenant_context["tenant_id"] + set_current_tenant(tenant_context) + if _VERBOSE_AUTH_LOG: + logger.info("Tenant detected from Host header: %s -> %s", host, requested_tenant_id) + return requested_tenant_id, tenant_context, "host header (virtual host)" + + # Fallback to subdomain extraction if virtual host lookup failed + subdomain = host.split(".")[0] if "." in host else None + if subdomain and subdomain not in ["localhost", "adcp-sales-agent", "www", "admin"]: + tenant_context = get_tenant_by_subdomain(subdomain) if tenant_context: requested_tenant_id = tenant_context["tenant_id"] - detection_method = "host header (virtual host)" set_current_tenant(tenant_context) if _VERBOSE_AUTH_LOG: - logger.info("Tenant detected from Host header: %s -> %s", host, requested_tenant_id) - else: - # Fallback to subdomain extraction if virtual host lookup failed - subdomain = host.split(".")[0] if "." in host else None - if subdomain and subdomain not in ["localhost", "adcp-sales-agent", "www", "admin"]: - tenant_context = get_tenant_by_subdomain(subdomain) - if tenant_context: - requested_tenant_id = tenant_context["tenant_id"] - detection_method = "subdomain" - set_current_tenant(tenant_context) - if _VERBOSE_AUTH_LOG: - logger.info("Tenant detected from subdomain: %s -> %s", subdomain, requested_tenant_id) - - # 2. Check x-adcp-tenant header (set by nginx for path-based routing) - if not requested_tenant_id: - tenant_hint = _get_header_case_insensitive(headers, "x-adcp-tenant") - if tenant_hint: - # Try to look up by subdomain first (most common case) - tenant_context = get_tenant_by_subdomain(tenant_hint) - if tenant_context: - requested_tenant_id = tenant_context["tenant_id"] - detection_method = "x-adcp-tenant header (subdomain lookup)" - set_current_tenant(tenant_context) - if _VERBOSE_AUTH_LOG: - logger.info("Tenant detected from x-adcp-tenant: %s -> %s", tenant_hint, requested_tenant_id) - else: - # Fallback: assume it's already a tenant_id - requested_tenant_id = tenant_hint - detection_method = "x-adcp-tenant header (direct)" - tenant_context = get_tenant_by_id(tenant_hint) - if tenant_context: - set_current_tenant(tenant_context) - - # 3. Check Apx-Incoming-Host header (for Approximated.app virtual hosts) - if not requested_tenant_id: - apx_host = _get_header_case_insensitive(headers, "apx-incoming-host") - if apx_host: - tenant_context = get_tenant_by_virtual_host(apx_host) - if tenant_context: - requested_tenant_id = tenant_context["tenant_id"] - detection_method = "apx-incoming-host" - set_current_tenant(tenant_context) - if _VERBOSE_AUTH_LOG: - logger.info("Tenant detected from Apx-Incoming-Host: %s -> %s", apx_host, requested_tenant_id) - - # 4. Fallback for localhost in development: use "default" tenant - if not requested_tenant_id: - host = _get_header_case_insensitive(headers, "host") or "" - hostname = host.split(":")[0] - if hostname in ["localhost", "127.0.0.1", "localhost.localdomain"]: - tenant_context = get_tenant_by_subdomain("default") - if tenant_context: - requested_tenant_id = tenant_context["tenant_id"] - detection_method = "localhost fallback (default tenant)" - set_current_tenant(tenant_context) + logger.info("Tenant detected from subdomain: %s -> %s", subdomain, requested_tenant_id) + return requested_tenant_id, tenant_context, "subdomain" + + return None, None, None + + +def _tenant_from_adcp_tenant_header(headers: dict) -> tuple[str | None, dict | None, str | None]: + """Resolve tenant from the x-adcp-tenant header — subdomain lookup first, then direct tenant_id.""" + tenant_hint = _get_header_case_insensitive(headers, "x-adcp-tenant") + if not tenant_hint: + return None, None, None + + # Try to look up by subdomain first (most common case) + tenant_context = get_tenant_by_subdomain(tenant_hint) + if tenant_context: + requested_tenant_id = tenant_context["tenant_id"] + set_current_tenant(tenant_context) + if _VERBOSE_AUTH_LOG: + logger.info("Tenant detected from x-adcp-tenant: %s -> %s", tenant_hint, requested_tenant_id) + return requested_tenant_id, tenant_context, "x-adcp-tenant header (subdomain lookup)" + + # Fallback: assume it's already a tenant_id + tenant_context = get_tenant_by_id(tenant_hint) + if tenant_context: + set_current_tenant(tenant_context) + return tenant_hint, tenant_context, "x-adcp-tenant header (direct)" + +def _tenant_from_apx_host_header(headers: dict) -> tuple[str | None, dict | None, str | None]: + """Resolve tenant from the Apx-Incoming-Host header via virtual host lookup.""" + apx_host = _get_header_case_insensitive(headers, "apx-incoming-host") + if not apx_host: + return None, None, None + + tenant_context = get_tenant_by_virtual_host(apx_host) + if not tenant_context: + return None, None, None + + requested_tenant_id = tenant_context["tenant_id"] + set_current_tenant(tenant_context) if _VERBOSE_AUTH_LOG: - if requested_tenant_id: - logger.info("Final tenant_id: %s (via %s)", requested_tenant_id, detection_method) - else: - logger.debug("No tenant detected from headers") + logger.info("Tenant detected from Apx-Incoming-Host: %s -> %s", apx_host, requested_tenant_id) + return requested_tenant_id, tenant_context, "apx-incoming-host" - # NOW check for auth token (after tenant resolution) + +def _tenant_from_localhost_fallback(headers: dict) -> tuple[str | None, dict | None, str | None]: + """Resolve the "default" tenant when the request host is localhost (development fallback).""" + host = _get_header_case_insensitive(headers, "host") or "" + hostname = host.split(":")[0] + if hostname not in ["localhost", "127.0.0.1", "localhost.localdomain"]: + return None, None, None + + tenant_context = get_tenant_by_subdomain("default") + if not tenant_context: + return None, None, None + + requested_tenant_id = tenant_context["tenant_id"] + set_current_tenant(tenant_context) + return requested_tenant_id, tenant_context, "localhost fallback (default tenant)" + + +def _extract_auth_token_from_headers(headers: dict) -> tuple[str | None, str | None]: + """Extract the auth token from x-adcp-auth or Authorization: Bearer headers, returning (token, source).""" # Accept either x-adcp-auth (preferred) or Authorization: Bearer (standard HTTP/MCP) # This ensures compatibility with MCP clients that only support Authorization header auth_token = _get_header_case_insensitive(headers, "x-adcp-auth") @@ -227,66 +314,27 @@ def get_principal_from_context( auth_token = potential_token auth_source = "Authorization: Bearer" - if _VERBOSE_AUTH_LOG and auth_source: - logger.info("Auth token found via: %s", auth_source) + return auth_token, auth_source - if not auth_token: - # Embedded-mode buyer-protocol identity-from-headers path - # (docs/design/embedded-mode.md §2): when a tenant is provisioned with - # ``is_embedded=True`` and the deployment opts in via - # ``MANAGED_INSTANCE=true``, callers identify the acting principal via - # ``X-Principal-Id`` (and the descriptive ``X-Identity-*`` headers from - # the same propagation contract used by the admin UI proxy). No - # protocol-level token check — trust is established by the network - # layer (the salesagent binds to a private interface and accepts - # buyer-protocol traffic only from the configured host product proxy). - embedded_principal_id = _try_resolve_embedded_buyer_identity(headers, tenant_context, require_valid_token) - if embedded_principal_id is not None: - return (embedded_principal_id, tenant_context) - logger.debug("No auth token found - OK for discovery endpoints") - return (None, tenant_context) - - # Validate token and get principal - # If requested_tenant_id is set: validate token belongs to that specific tenant - # If requested_tenant_id is None: do global lookup and set tenant context from token - if not requested_tenant_id: - # No tenant detected from headers - use global token lookup - # SECURITY NOTE: This is safe because get_principal_from_token() will: - # 1. Look up the token globally - # 2. Find which tenant it belongs to - # 3. Return (principal_id, tenant_dict) — caller sets context - # 4. Return principal_id only if token is valid for that tenant - logger.debug("Using global token lookup (finds tenant from token)") - detection_method = "global token lookup" - - principal_id, token_tenant = get_principal_from_token(auth_token, requested_tenant_id) - - # If token was provided but invalid, raise an error (unless require_valid_token=False for discovery) - # This distinguishes between "no auth" (OK) and "bad auth" (error or warning) - if principal_id is None: - if require_valid_token: - from src.core.exceptions import AdCPAuthenticationError +def _reject_or_ignore_invalid_token( + requested_tenant_id: str | None, tenant_context: dict | None, require_valid_token: bool +) -> tuple[None, dict | None]: + """Raise for an invalid auth token, or continue unauthenticated when require_valid_token is False.""" + if require_valid_token: + from src.core.exceptions import AdCPAuthenticationError - raise AdCPAuthenticationError( - f"Authentication token is invalid for tenant '{requested_tenant_id or 'any'}'. " - f"The token may be expired, revoked, or associated with a different tenant.", - details={"error_code": "INVALID_AUTH_TOKEN"}, - ) - # For discovery endpoints, treat invalid token like missing token - logger.debug( - "Invalid token for tenant '%s' - continuing without auth (discovery endpoint)", - requested_tenant_id or "any", + raise AdCPAuthenticationError( + f"Authentication token is invalid for tenant '{requested_tenant_id or 'any'}'. " + f"The token may be expired, revoked, or associated with a different tenant.", + details={"error_code": "INVALID_AUTH_TOKEN"}, ) - return (None, tenant_context) - - # If tenant_context wasn't set by header detection, use tenant discovered from token - if not tenant_context and token_tenant: - tenant_context = token_tenant - - # Return both principal_id and tenant_context explicitly - # Caller MUST call set_current_tenant(tenant_context) in their async context - return (principal_id, tenant_context) + # For discovery endpoints, treat invalid token like missing token + logger.debug( + "Invalid token for tenant '%s' - continuing without auth (discovery endpoint)", + requested_tenant_id or "any", + ) + return (None, tenant_context) def _try_resolve_embedded_buyer_identity( From d6bf7ae22adfafcbfccb943ebe10b5ef141ecee7 Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 16 Jul 2026 23:23:15 +0600 Subject: [PATCH 14/17] docs: record SOLID/KISS/DRY pass results and spend-limit blockers Co-Authored-By: Claude Fable 5 --- .../codebase-improvement-2026-07.md | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/development/codebase-improvement-2026-07.md b/docs/development/codebase-improvement-2026-07.md index 9f9b99123b..d771297202 100644 --- a/docs/development/codebase-improvement-2026-07.md +++ b/docs/development/codebase-improvement-2026-07.md @@ -2,7 +2,28 @@ Branch: `feature/refactor-all` (base `26fa253bf`). Ten commits, `10cff0b0a..31668d163`. -## Continuation (same day): mypy strictness + refactor exemplar +## Continuation 2 (same day): SOLID/KISS/DRY pass + +- **Correctness** (`b4f8fb143`): Broadstreet `delete_*` normalize empty 204 bodies to `{}` + (declared dict, returned None); `create_placement` now unwraps the response envelope like + its siblings; mock `_is_simulation` returns a real bool. +- **SRP/KISS** (`5e971b5ad`): `get_principal_from_context` (C=41) decomposed with a + 28-case golden master locking outputs, exceptions, AND dependency call order; + `parse_form_data_to_policy_updates` (C=43) and `parse_pricing_options_from_form` (C=31) + decomposed with empty golden diffs. Unit suite per-test set-diff: zero new failures. + Duplication ratchet tightened 18 → 17. +- **Blocked by org monthly spend limit** (subagents terminated mid-run): the + `_get_media_buy_delivery_impl` (C=54) decomposition (partial edit reverted) and the + DRY triage table for the remaining baseline blocks. Resume both when the limit resets. +- Honest scope statement: "whole-codebase SOLID" is a program, not a session. The macro + architecture already enforces DIP/OCP via adapters/repositories/AST guards; the tracked + gap is function-level SRP (55 functions still >C20 — worst: `_create_media_buy_impl` + C=239, `_update_media_buy_impl` C=126, `edit_product` C=87). The three refactors above + plus the earlier activity-formatter one establish the verified pattern (golden-master + + set-diff) to burn that list down; the C=239 media-buy monster should get a dedicated + effort with the Docker integration suite running. + +## Continuation 1 (same day): mypy strictness + refactor exemplar - **`warn_return_any = True` is now enforced** (mypy.ini roadmap step 1 executed): all 86 `no-any-return` sites fixed across 43 files with typed intermediates/casts — no behavior From afbb3a73a0e8bf4f3fa3e4e33fccc0962ad28f2c Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 16 Jul 2026 23:42:10 +0600 Subject: [PATCH 15/17] perf: single windowed query for revenue trend; hoist repeated work - Dashboard revenue trend issued one DB query per day (30 per dashboard load, 365 for YTD). MediaBuyRepository gains list_in_flight_between(); the trend fetches the window once and buckets per day in Python with identical per-buy attribution math. list_in_flight_on_date delegates to the windowed query. - Product-suggestions API rebuilt the default-products catalog inside the sort key and again per suggestion (O(n^2)); the default-ID set is now computed once per request. - src/services/ai_parsing_comparison.py (unimported CLI tool, never part of the server) moves to scripts/ops/ where it is runnable without shipping in the service import tree. Unit suite: totals unchanged (307/5962/24), per-test set-diff zero new failures; mypy/ruff/duplication gates green. Co-Authored-By: Claude Fable 5 --- .../ops}/ai_parsing_comparison.py | 0 src/admin/blueprints/api.py | 8 ++++---- src/admin/services/dashboard_service.py | 12 +++++++++++- src/core/database/repositories/media_buy.py | 16 ++++++++++++++-- 4 files changed, 29 insertions(+), 7 deletions(-) rename {src/services => scripts/ops}/ai_parsing_comparison.py (100%) diff --git a/src/services/ai_parsing_comparison.py b/scripts/ops/ai_parsing_comparison.py similarity index 100% rename from src/services/ai_parsing_comparison.py rename to scripts/ops/ai_parsing_comparison.py diff --git a/src/admin/blueprints/api.py b/src/admin/blueprints/api.py index 577e34c87c..ab5cefae0a 100644 --- a/src/admin/blueprints/api.py +++ b/src/admin/blueprints/api.py @@ -187,8 +187,10 @@ def get_product_suggestions(tenant_id): # Sort suggestions by relevance # Prioritize: 1) Industry-specific, 2) Lower CPM, 3) More formats + default_product_ids = {p["product_id"] for p in get_default_products()} + def sort_key(product): - is_industry_specific = product["product_id"] not in [p["product_id"] for p in get_default_products()] + is_industry_specific = product["product_id"] not in default_product_ids avg_cpm = ( product.get("cpm", 0) or (product.get("price_guidance", {}).get("min", 0) + product.get("price_guidance", {}).get("max", 0)) @@ -207,9 +209,7 @@ def sort_key(product): # Add metadata to suggestions for suggestion in filtered_suggestions: suggestion["already_exists"] = suggestion["product_id"] in existing_ids - suggestion["is_industry_specific"] = suggestion["product_id"] not in [ - p["product_id"] for p in get_default_products() - ] + suggestion["is_industry_specific"] = suggestion["product_id"] not in default_product_ids # Calculate match score (0-100) score = 100 diff --git a/src/admin/services/dashboard_service.py b/src/admin/services/dashboard_service.py index 248fee353f..9a1ebe3695 100644 --- a/src/admin/services/dashboard_service.py +++ b/src/admin/services/dashboard_service.py @@ -196,10 +196,20 @@ def _calculate_revenue_trend( today = datetime.now(UTC).date() revenue_data = [] + # One windowed query for the whole trend, bucketed per day in Python — + # replaces a query per day (30 on the dashboard, 365 for YTD). + window_start = anchor - timedelta(days=days - 1) + window_buys = [ + (buy, buy_start, buy_end) + for buy in repo.list_in_flight_between(window_start, anchor, statuses=["active", "completed"]) + if (buy_start := type_cast(date | None, buy.start_date)) is not None + and (buy_end := type_cast(date | None, buy.end_date)) is not None + ] + for i in range(days): day = anchor - timedelta(days=days - 1 - i) - daily_buys = repo.list_in_flight_on_date(day, statuses=["active", "completed"]) + daily_buys = [buy for buy, buy_start, buy_end in window_buys if buy_start <= day <= buy_end] daily_revenue = 0.0 for buy in daily_buys: diff --git a/src/core/database/repositories/media_buy.py b/src/core/database/repositories/media_buy.py index 3aa81902f3..35d5b6fdd8 100644 --- a/src/core/database/repositories/media_buy.py +++ b/src/core/database/repositories/media_buy.py @@ -369,10 +369,22 @@ def list_in_flight_on_date( Useful for revenue trend calculations. """ + return self.list_in_flight_between(target_date, target_date, statuses=statuses) + + def list_in_flight_between( + self, + window_start: datetime.date, + window_end: datetime.date, + statuses: list[str] | None = None, + ) -> list[MediaBuy]: + """Get media buys whose flight period overlaps [window_start, window_end]. + + One windowed query replaces per-day lookups in trend calculations. + """ stmt = select(MediaBuy).where( MediaBuy.tenant_id == self._tenant_id, - MediaBuy.start_date <= target_date, - MediaBuy.end_date >= target_date, + MediaBuy.start_date <= window_end, + MediaBuy.end_date >= window_start, ) if statuses: stmt = stmt.where(MediaBuy.status.in_(statuses)) From 715b05c5b6749c6c7e546da4a7c9eacc022e4bac Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 16 Jul 2026 23:53:52 +0600 Subject: [PATCH 16/17] perf: load media-buy delivery metrics asynchronously after page render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The details page called adapter.get_media_buy_delivery() inline — a live GAM Reporting API round trip blocking every page visit. The page now renders instantly from the persisted delivery snapshot (delivered_impressions/delivered_amount/delivery_synced_at), and a new GET /media-buy//delivery-metrics JSON endpoint (api_mode auth, so expired sessions get JSON not HTML) performs the reporting fetch after page load, refreshes the snapshot, and the template updates the metrics in place — with an explicit snapshot/live/unavailable status line. Package IDs from the adapter are inserted via textContent, never as HTML. Route-conflict hook clean; unit totals unchanged (307/5962/24, zero new failures). Co-Authored-By: Claude Fable 5 --- src/admin/blueprints/operations.py | 173 ++++++++++++++++++----------- templates/media_buy_detail.html | 83 ++++++++++++-- 2 files changed, 178 insertions(+), 78 deletions(-) diff --git a/src/admin/blueprints/operations.py b/src/admin/blueprints/operations.py index 949850cd89..70a3e740ce 100644 --- a/src/admin/blueprints/operations.py +++ b/src/admin/blueprints/operations.py @@ -7,7 +7,7 @@ from adcp import create_a2a_webhook_payload, create_mcp_webhook_payload from adcp.types import CreateMediaBuySuccessResponse, Package from adcp.types import GeneratedTaskStatus as AdcpTaskStatus -from flask import Blueprint, request +from flask import Blueprint, jsonify, request from sqlalchemy import select from src.admin.utils import require_tenant_access @@ -279,74 +279,22 @@ def media_buy_detail(tenant_id, media_buy_id): "message": "This media buy is pending. It may be waiting for creatives or other requirements.", } - # Fetch delivery metrics if media buy is active or completed + # Delivery metrics: render instantly from the persisted snapshot. + # The live adapter fetch (a GAM Reporting API round trip that used + # to block every page visit) moved to the JSON endpoint below; the + # template fetches it after load and updates in place. delivery_metrics = None if media_buy.status in ["active", "approved", "completed"]: - try: - from datetime import UTC, datetime, timedelta - - from src.core.config_loader import set_current_tenant - from src.core.database.models import Tenant - from src.core.helpers.adapter_helpers import get_adapter - from src.core.schemas import Principal as PrincipalSchema - from src.core.schemas import ReportingPeriod - - # Get adapter for this principal - if principal: - # Set tenant context before calling get_adapter (required for adapter initialization) - tenant = db_session.scalars(select(Tenant).filter_by(tenant_id=tenant_id)).first() - if tenant: - set_current_tenant( - { - "tenant_id": tenant_id, - "ad_server": tenant.ad_server or "mock", - } - ) - - # Convert SQLAlchemy model to Pydantic schema (get_adapter expects schema) - principal_schema = PrincipalSchema( - principal_id=principal.principal_id, - name=principal.name, - platform_mappings=principal.platform_mappings or {}, - ) - adapter = get_adapter(principal_schema, dry_run=False) - - # Request all-time delivery, independent of the buy's - # flight/schedule dates. A span over a year makes the - # GAM adapter classify the request as "all_time" (full - # GAM data retention, aggregated); shorter spans - # collapse to "today"/"this_month"/"lifetime" and drop - # delivery from earlier periods. - end_date = datetime.now(UTC) - start_date = end_date - timedelta(days=3 * 365) - - reporting_period = ReportingPeriod(start=start_date, end=end_date) - - # Fetch delivery metrics from adapter - delivery_response = adapter.get_media_buy_delivery( - media_buy_id=media_buy_id, date_range=reporting_period, today=datetime.now(UTC) - ) - - delivery_metrics = { - "impressions": delivery_response.totals.impressions, - "spend": delivery_response.totals.spend, - "clicks": delivery_response.totals.clicks, - "ctr": delivery_response.totals.ctr, - "currency": delivery_response.currency, - "by_package": delivery_response.by_package, - } - - # Persist delivered_amount so the dashboard Running - # column and pacing bars reflect real GAM data. - from decimal import Decimal as _Decimal - - media_buy.delivered_amount = _Decimal(str(round(delivery_response.totals.spend, 2))) - media_buy.delivered_impressions = int(delivery_response.totals.impressions or 0) - media_buy.delivery_synced_at = datetime.now(UTC) - db_session.commit() - except Exception as e: - logger.warning(f"Could not fetch delivery metrics for {media_buy_id}: {e}") - # Continue without metrics - don't fail the whole page + delivery_metrics = { + "impressions": media_buy.delivered_impressions or 0, + "spend": float(media_buy.delivered_amount) if media_buy.delivered_amount is not None else 0.0, + "clicks": None, + "ctr": None, + "currency": media_buy.currency or "USD", + "by_package": [], + "pending_live": True, + "synced_at": media_buy.delivery_synced_at.isoformat() if media_buy.delivery_synced_at else None, + } # #101 — webhook delivery activity for the per-buy admin tab. # Operator-scoped: shows deliveries from any principal that @@ -380,6 +328,97 @@ def media_buy_detail(tenant_id, media_buy_id): return "Error loading media buy", 500 +@operations_bp.route("/media-buy//delivery-metrics", methods=["GET"]) +@require_tenant_access(api_mode=True) +def media_buy_delivery_metrics(tenant_id, media_buy_id): + """Live delivery metrics for the details page, fetched after page load. + + Runs the ad-server reporting round trip that previously blocked the + page render, and refreshes the persisted delivery snapshot. + """ + from datetime import UTC, datetime, timedelta + from decimal import Decimal + + from src.core.config_loader import set_current_tenant + from src.core.database.database_session import get_db_session + from src.core.database.models import Principal, Tenant + from src.core.helpers.adapter_helpers import get_adapter + from src.core.schemas import Principal as PrincipalSchema + from src.core.schemas import ReportingPeriod + + try: + with get_db_session() as db_session: + repo = MediaBuyRepository(db_session, tenant_id) + media_buy = repo.get_by_id(media_buy_id) + if not media_buy: + return jsonify({"error": "Media buy not found"}), 404 + if media_buy.status not in ["active", "approved", "completed"]: + return jsonify({"delivery_metrics": None}) + + principal = None + if media_buy.principal_id: + stmt = select(Principal).filter_by(tenant_id=tenant_id, principal_id=media_buy.principal_id) + principal = db_session.scalars(stmt).first() + if not principal: + return jsonify({"delivery_metrics": None}) + + # Set tenant context before calling get_adapter (required for adapter initialization) + tenant = db_session.scalars(select(Tenant).filter_by(tenant_id=tenant_id)).first() + if tenant: + set_current_tenant({"tenant_id": tenant_id, "ad_server": tenant.ad_server or "mock"}) + + principal_schema = PrincipalSchema( + principal_id=principal.principal_id, + name=principal.name, + platform_mappings=principal.platform_mappings or {}, + ) + adapter = get_adapter(principal_schema, dry_run=False) + + # Request all-time delivery, independent of the buy's + # flight/schedule dates. A span over a year makes the GAM + # adapter classify the request as "all_time" (full GAM data + # retention, aggregated); shorter spans collapse to + # "today"/"this_month"/"lifetime" and drop earlier periods. + end_date = datetime.now(UTC) + reporting_period = ReportingPeriod(start=end_date - timedelta(days=3 * 365), end=end_date) + + delivery_response = adapter.get_media_buy_delivery( + media_buy_id=media_buy_id, date_range=reporting_period, today=datetime.now(UTC) + ) + + # Persist delivered_amount so the dashboard Running column and + # pacing bars reflect real GAM data. + media_buy.delivered_amount = Decimal(str(round(delivery_response.totals.spend, 2))) + media_buy.delivered_impressions = int(delivery_response.totals.impressions or 0) + media_buy.delivery_synced_at = datetime.now(UTC) + db_session.commit() + + return jsonify( + { + "delivery_metrics": { + "impressions": delivery_response.totals.impressions, + "spend": delivery_response.totals.spend, + "clicks": delivery_response.totals.clicks, + "ctr": delivery_response.totals.ctr, + "currency": delivery_response.currency, + "by_package": [ + { + "package_id": pkg.package_id, + "impressions": pkg.impressions, + "spend": pkg.spend, + "clicks": getattr(pkg, "clicks", None), + } + for pkg in delivery_response.by_package + ], + "synced_at": media_buy.delivery_synced_at.isoformat(), + } + } + ) + except Exception as e: + logger.warning(f"Could not fetch live delivery metrics for {media_buy_id}: {e}") + return jsonify({"error": "Could not fetch live delivery metrics"}), 502 + + @operations_bp.route("/media-buy//approve", methods=["POST"]) @require_tenant_access(role=("admin",), allow_embedded_writes=True) def approve_media_buy(tenant_id, media_buy_id, **kwargs): diff --git a/templates/media_buy_detail.html b/templates/media_buy_detail.html index 706aff5f88..f2dd4fab33 100644 --- a/templates/media_buy_detail.html +++ b/templates/media_buy_detail.html @@ -123,26 +123,23 @@

📊 Delivery Metrics

Impressions: - {{ "{:,}".format(delivery_metrics.impressions|int) }} + {{ "{:,}".format(delivery_metrics.impressions|int) }}
Spend: - {{ delivery_metrics.currency }} {{ "{:,.2f}".format(delivery_metrics.spend) }} + {{ delivery_metrics.currency }} {{ "{:,.2f}".format(delivery_metrics.spend) }}
- {% if delivery_metrics.clicks %} -
+
Clicks: - {{ "{:,}".format(delivery_metrics.clicks|int) }} + {% if delivery_metrics.clicks %}{{ "{:,}".format(delivery_metrics.clicks|int) }}{% endif %}
- {% endif %} - {% if delivery_metrics.ctr %} -
+
CTR: - {{ "{:.2f}".format(delivery_metrics.ctr) }}% + {% if delivery_metrics.ctr %}{{ "{:.2f}".format(delivery_metrics.ctr) }}%{% endif %}
- {% endif %}
+
{% if delivery_metrics.by_package %}

By Package

@@ -171,11 +168,75 @@

By Package

{% endif %} +
- Metrics fetched from {{ media_buy.adapter_type|default('ad server') }} in real-time + + {%- if delivery_metrics.pending_live -%} + Snapshot{% if delivery_metrics.synced_at %} from {{ delivery_metrics.synced_at }}{% endif %} — refreshing live metrics… + {%- else -%} + Metrics fetched from {{ media_buy.adapter_type|default('ad server') }} in real-time + {%- endif -%} +
+{% if delivery_metrics.pending_live %} + +{% endif %} {% elif media_buy.status in ['active', 'approved', 'completed'] %}
From 6cfc1c05415477b5798040f47c876f9492fbdb32 Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Fri, 17 Jul 2026 04:07:07 +0600 Subject: [PATCH 17/17] fix: propagate ADCP_SALES_PORT authoritatively in run_server launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Docker image bakes ENV ADCP_PORT=8000 for the run_all_services entrypoint; the compose test path launches run_server.py with ADCP_SALES_PORT=8080 and healthchecks 8080. setdefault("ADCP_PORT") silently lost to the baked ENV, so the unified server bound 8000 while nginx proxied to 8080 — the stack could never become healthy and ./run_all_tests.sh aborted before any suite ran. Assign instead of setdefault; verified by a full stack run reaching healthy with zero nginx upstream errors and the admin (128 passed) and ui (7 passed) suites completing through the proxy. Co-Authored-By: Claude Fable 5 --- scripts/run_server.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/run_server.py b/scripts/run_server.py index 33447458b8..b4ce821113 100755 --- a/scripts/run_server.py +++ b/scripts/run_server.py @@ -34,7 +34,11 @@ def main(): print(f"Starting AdCP Sales Agent on {host}:{port}") print(f"Server endpoint: http://{host}:{port}/") - os.environ.setdefault("ADCP_PORT", str(port)) + # ADCP_SALES_PORT is this launcher's operator-facing knob (compose sets it + # to 8080 and healthchecks that port). Assign — not setdefault — so the + # image-baked ENV ADCP_PORT=8000 (used by the run_all_services entrypoint) + # can't silently win and leave nginx proxying to a port nobody listens on. + os.environ["ADCP_PORT"] = str(port) from core.main import main as _core_main try: