|
@@ -1,7 +1,74 @@
|
|
|
|
|
+import json
|
|
|
|
|
+import os
|
|
|
|
|
+import tempfile
|
|
|
import unittest
|
|
import unittest
|
|
|
|
|
+from contextlib import contextmanager
|
|
|
|
|
+from datetime import date, datetime
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+from unittest.mock import Mock, patch
|
|
|
|
|
+from zoneinfo import ZoneInfo
|
|
|
|
|
+
|
|
|
|
|
+import pandas as pd
|
|
|
|
|
+from openpyxl import load_workbook
|
|
|
|
|
|
|
|
|
|
|
|
|
class CreativeReviewScanTests(unittest.TestCase):
|
|
class CreativeReviewScanTests(unittest.TestCase):
|
|
|
|
|
+ def test_tencent_get_retries_transient_tls_connection_failure(self):
|
|
|
|
|
+ from tools import ad_api
|
|
|
|
|
+
|
|
|
|
|
+ response = Mock()
|
|
|
|
|
+ response.raise_for_status.return_value = None
|
|
|
|
|
+ response.json.return_value = {"code": 0, "data": {"list": []}}
|
|
|
|
|
+ connection_error = ad_api.httpx.ConnectError("temporary TLS EOF")
|
|
|
|
|
+
|
|
|
|
|
+ with patch.dict(
|
|
|
|
|
+ os.environ,
|
|
|
|
|
+ {
|
|
|
|
|
+ "TENCENT_AD_GET_RETRY_ATTEMPTS": "3",
|
|
|
|
|
+ "TENCENT_AD_GET_RETRY_BACKOFF_SECONDS": "0",
|
|
|
|
|
+ },
|
|
|
|
|
+ clear=False,
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ ad_api, "_common_params", side_effect=[{"nonce": "1"}, {"nonce": "2"}]
|
|
|
|
|
+ ) as common_params, patch.object(
|
|
|
|
|
+ ad_api.httpx,
|
|
|
|
|
+ "get",
|
|
|
|
|
+ side_effect=[connection_error, response],
|
|
|
|
|
+ create=True,
|
|
|
|
|
+ ) as get:
|
|
|
|
|
+ payload = ad_api._get("/test/get", {"account_id": 123})
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(payload, {"code": 0, "data": {"list": []}})
|
|
|
|
|
+ self.assertEqual(get.call_count, 2)
|
|
|
|
|
+ self.assertEqual(common_params.call_count, 2)
|
|
|
|
|
+
|
|
|
|
|
+ def test_access_tokens_are_prefetched_concurrently_and_deduplicated(self):
|
|
|
|
|
+ import threading
|
|
|
|
|
+
|
|
|
|
|
+ from tools import ad_api
|
|
|
|
|
+
|
|
|
|
|
+ barrier = threading.Barrier(3, timeout=2)
|
|
|
|
|
+
|
|
|
|
|
+ def fetch(account_id):
|
|
|
|
|
+ barrier.wait()
|
|
|
|
|
+ return f"access-token-{account_id}"
|
|
|
|
|
+
|
|
|
|
|
+ with patch.dict(ad_api._token_cache, {}, clear=True), patch.object(
|
|
|
|
|
+ ad_api,
|
|
|
|
|
+ "_get_access_token",
|
|
|
|
|
+ side_effect=fetch,
|
|
|
|
|
+ ) as get_token:
|
|
|
|
|
+ tokens = ad_api.prefetch_access_tokens([3, 1, 2, 2], max_workers=3)
|
|
|
|
|
+ cached_accounts = set(ad_api._token_cache)
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(tokens, {
|
|
|
|
|
+ 1: "access-token-1",
|
|
|
|
|
+ 2: "access-token-2",
|
|
|
|
|
+ 3: "access-token-3",
|
|
|
|
|
+ })
|
|
|
|
|
+ self.assertEqual(get_token.call_count, 3)
|
|
|
|
|
+ self.assertEqual(cached_accounts, {1, 2, 3})
|
|
|
|
|
+
|
|
|
def test_parse_rejected_result_collects_reasons_and_locations(self):
|
|
def test_parse_rejected_result_collects_reasons_and_locations(self):
|
|
|
from tools.creative_review import parse_review_result
|
|
from tools.creative_review import parse_review_result
|
|
|
|
|
|
|
@@ -50,6 +117,83 @@ class CreativeReviewScanTests(unittest.TestCase):
|
|
|
self.assertEqual(parsed.review_status, "pending")
|
|
self.assertEqual(parsed.review_status, "pending")
|
|
|
self.assertIn("审核延迟", parsed.delay_messages)
|
|
self.assertIn("审核延迟", parsed.delay_messages)
|
|
|
|
|
|
|
|
|
|
+ def test_review_granularity_fields_lists_element_and_site_results(self):
|
|
|
|
|
+ from tools.creative_review import review_granularity_fields
|
|
|
|
|
+
|
|
|
|
|
+ fields = review_granularity_fields(
|
|
|
|
|
+ {
|
|
|
|
|
+ "element_result_list": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "element_name": "主图",
|
|
|
|
|
+ "image_id": "10001",
|
|
|
|
|
+ "review_status": "REVIEW_STATUS_REJECTED",
|
|
|
|
|
+ "element_reject_detail_info": [
|
|
|
|
|
+ {"reason": "图片违规"},
|
|
|
|
|
+ {"reason": "文字违规"},
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "element_type": "ELEMENT_TYPE_VIDEO",
|
|
|
|
|
+ "video_id": "20002",
|
|
|
|
|
+ "review_status": "REVIEW_STATUS_APPROVED",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "element_name": "正常元素",
|
|
|
|
|
+ "element_id": "30003",
|
|
|
|
|
+ "system_status": "AD_STATUS_NORMAL",
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ "site_set_result_list": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "site_set": "SITE_SET_MOMENTS",
|
|
|
|
|
+ "site_set_id": "40004",
|
|
|
|
|
+ "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
|
|
|
|
|
+ "reject_message": "朋友圈版位拒绝",
|
|
|
|
|
+ "element_reject_detail_info": [
|
|
|
|
|
+ {"reason": "版位素材不适配"}
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "site_set": "SITE_SET_WECHAT_CHANNELS",
|
|
|
|
|
+ "review_status": "REVIEW_STATUS_APPROVED",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "site_set": "SITE_SET_NORMAL",
|
|
|
|
|
+ "review_status": "AD_STATUS_NORMAL",
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ fields["element_review_status"],
|
|
|
|
|
+ "主图(id=10001): 审核拒绝\nELEMENT_TYPE_VIDEO: 审核通过",
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ fields["element_reject_reason"],
|
|
|
|
|
+ "主图(id=10001): 图片违规\n主图(id=10001): 文字违规",
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ fields["site_review_status"],
|
|
|
|
|
+ "SITE_SET_MOMENTS(id=40004): 审核拒绝\n"
|
|
|
|
|
+ "SITE_SET_WECHAT_CHANNELS: 审核通过",
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ fields["site_reject_reason"],
|
|
|
|
|
+ "SITE_SET_MOMENTS(id=40004): 朋友圈版位拒绝\n"
|
|
|
|
|
+ "SITE_SET_MOMENTS(id=40004): 版位素材不适配",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def test_status_desc_preserves_unknown_enum(self):
|
|
|
|
|
+ from tools.creative_review import status_desc
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(status_desc("AD_STATUS_NORMAL"), "投放中")
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ status_desc("CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"),
|
|
|
|
|
+ "部分投放中",
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(status_desc("FUTURE_STATUS_NEW"), "FUTURE_STATUS_NEW")
|
|
|
|
|
+
|
|
|
def test_chunk_ids_by_account_limits_to_100(self):
|
|
def test_chunk_ids_by_account_limits_to_100(self):
|
|
|
from tools.creative_review import group_review_tasks
|
|
from tools.creative_review import group_review_tasks
|
|
|
|
|
|
|
@@ -68,5 +212,2048 @@ class CreativeReviewScanTests(unittest.TestCase):
|
|
|
self.assertEqual(groups[3], (2, [999]))
|
|
self.assertEqual(groups[3], (2, [999]))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+class CreativeRejectionCleanupTests(unittest.TestCase):
|
|
|
|
|
+ def test_dynamic_creative_api_requests_creative_approval_status(self):
|
|
|
|
|
+ from tencent_client import DYNAMIC_CREATIVE_FIELDS, TencentClient
|
|
|
|
|
+
|
|
|
|
|
+ response = Mock()
|
|
|
|
|
+ response.raise_for_status.return_value = None
|
|
|
|
|
+ response.json.return_value = {
|
|
|
|
|
+ "code": 0,
|
|
|
|
|
+ "data": {"list": [], "page_info": {"total_page": 1}},
|
|
|
|
|
+ }
|
|
|
|
|
+ client = TencentClient()
|
|
|
|
|
+ client.session.get = Mock(return_value=response)
|
|
|
|
|
+ client._common_params = Mock(return_value={"access_token": "token"})
|
|
|
|
|
+
|
|
|
|
|
+ client.get_dynamic_creatives(1)
|
|
|
|
|
+
|
|
|
|
|
+ self.assertIn("creative_set_approval_status", DYNAMIC_CREATIVE_FIELDS)
|
|
|
|
|
+ requested_fields = json.loads(client.session.get.call_args.kwargs["params"]["fields"])
|
|
|
|
|
+ self.assertIn("creative_set_approval_status", requested_fields)
|
|
|
|
|
+
|
|
|
|
|
+ def test_dynamic_creative_costs_query_sums_three_day_api_rows(self):
|
|
|
|
|
+ from datetime import date
|
|
|
|
|
+
|
|
|
|
|
+ from tencent_client import TencentClient
|
|
|
|
|
+
|
|
|
|
|
+ response = Mock()
|
|
|
|
|
+ response.raise_for_status.return_value = None
|
|
|
|
|
+ response.json.return_value = {
|
|
|
|
|
+ "code": 0,
|
|
|
|
|
+ "data": {
|
|
|
|
|
+ "list": [
|
|
|
|
|
+ {"dynamic_creative_id": 3, "date": "2026-08-10", "cost": 1200},
|
|
|
|
|
+ {"dynamic_creative_id": 3, "date": "2026-08-11", "cost": 1799},
|
|
|
|
|
+ {"dynamic_creative_id": 4, "date": "2026-08-12", "cost": 3000},
|
|
|
|
|
+ ],
|
|
|
|
|
+ "page_info": {"total_page": 1},
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ client = TencentClient()
|
|
|
|
|
+ client.session.get = Mock(return_value=response)
|
|
|
|
|
+ client._common_params = Mock(return_value={"access_token": "token"})
|
|
|
|
|
+
|
|
|
|
|
+ costs = client.get_dynamic_creative_costs(
|
|
|
|
|
+ 1,
|
|
|
|
|
+ [3, 4],
|
|
|
|
|
+ date(2026, 8, 10),
|
|
|
|
|
+ date(2026, 8, 12),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(costs, {3: 2999, 4: 3000})
|
|
|
|
|
+ request = client.session.get.call_args
|
|
|
|
|
+ self.assertTrue(
|
|
|
|
|
+ request.args[0].endswith("/daily_reports/get")
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ request.kwargs["params"]["level"],
|
|
|
|
|
+ "REPORT_LEVEL_DYNAMIC_CREATIVE",
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ json.loads(request.kwargs["params"]["date_range"]),
|
|
|
|
|
+ {"start_date": "2026-08-10", "end_date": "2026-08-12"},
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ json.loads(request.kwargs["params"]["filtering"]),
|
|
|
|
|
+ [
|
|
|
|
|
+ {
|
|
|
|
|
+ "field": "dynamic_creative_id",
|
|
|
|
|
+ "operator": "IN",
|
|
|
|
|
+ "values": ["3", "4"],
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(request.kwargs["params"]["time_line"], "REQUEST_TIME")
|
|
|
|
|
+
|
|
|
|
|
+ def test_read_only_account_scan_runs_concurrently_with_isolated_clients(self):
|
|
|
|
|
+ import threading
|
|
|
|
|
+
|
|
|
|
|
+ from report_rejected_creatives_once import collect_rejected_creatives
|
|
|
|
|
+
|
|
|
|
|
+ barrier = threading.Barrier(2, timeout=2)
|
|
|
|
|
+ created_clients = []
|
|
|
|
|
+ cost_requests = []
|
|
|
|
|
+
|
|
|
|
|
+ class Session:
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.closed = False
|
|
|
|
|
+
|
|
|
|
|
+ def close(self):
|
|
|
|
|
+ self.closed = True
|
|
|
|
|
+
|
|
|
|
|
+ class Client:
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.session = Session()
|
|
|
|
|
+ created_clients.append(self)
|
|
|
|
|
+
|
|
|
|
|
+ def get_dynamic_creatives(self, account_id):
|
|
|
|
|
+ barrier.wait()
|
|
|
|
|
+ return [{
|
|
|
|
|
+ "dynamic_creative_id": account_id * 10,
|
|
|
|
|
+ "dynamic_creative_name": f"创意{account_id}",
|
|
|
|
|
+ "adgroup_id": account_id * 100,
|
|
|
|
|
+ "configured_status": "AD_STATUS_NORMAL",
|
|
|
|
|
+ "creative_set_approval_status": (
|
|
|
|
|
+ "CREATIVE_SET_APPROVAL_STATUS_DENIED"
|
|
|
|
|
+ ),
|
|
|
|
|
+ "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
|
|
|
|
|
+ }]
|
|
|
|
|
+
|
|
|
|
|
+ def get_ads(self, account_id):
|
|
|
|
|
+ return [{
|
|
|
|
|
+ "adgroup_id": account_id * 100,
|
|
|
|
|
+ "adgroup_name": f"广告{account_id}",
|
|
|
|
|
+ }]
|
|
|
|
|
+
|
|
|
|
|
+ def get_dynamic_creative_costs(
|
|
|
|
|
+ self, account_id, creative_ids, start_date, end_date
|
|
|
|
|
+ ):
|
|
|
|
|
+ cost_requests.append(
|
|
|
|
|
+ (account_id, list(creative_ids), start_date, end_date)
|
|
|
|
|
+ )
|
|
|
|
|
+ return {creative_id: account_id * 100 for creative_id in creative_ids}
|
|
|
|
|
+
|
|
|
|
|
+ rows, errors, scanned = collect_rejected_creatives(
|
|
|
|
|
+ accounts=[{"account_id": 1}, {"account_id": 2}],
|
|
|
|
|
+ agency_context={
|
|
|
|
|
+ "creative_agencies": {},
|
|
|
|
|
+ "account_agencies": {},
|
|
|
|
|
+ "account_names": {},
|
|
|
|
|
+ },
|
|
|
|
|
+ tencent=Mock(),
|
|
|
|
|
+ tencent_factory=Client,
|
|
|
|
|
+ review_fetcher=lambda _account, _ids: [],
|
|
|
|
|
+ checked_at=datetime(2026, 8, 12, 11, 0),
|
|
|
|
|
+ max_workers=2,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(errors, [])
|
|
|
|
|
+ self.assertEqual(scanned, 2)
|
|
|
|
|
+ self.assertEqual([row["账户ID"] for row in rows], [1, 2])
|
|
|
|
|
+ self.assertTrue(all(row["配置状态"] == "投放中" for row in rows))
|
|
|
|
|
+ self.assertTrue(
|
|
|
|
|
+ all(
|
|
|
|
|
+ row["创意审核状态"] == "审核拒绝"
|
|
|
|
|
+ for row in rows
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ [row["近3天历史消耗(元)"] for row in rows],
|
|
|
|
|
+ ["1.00", "2.00"],
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertTrue(all(row["执行操作"] == "删除创意" for row in rows))
|
|
|
|
|
+ self.assertEqual(len(cost_requests), 2)
|
|
|
|
|
+ self.assertTrue(
|
|
|
|
|
+ all(request[2].isoformat() == "2026-08-09" for request in cost_requests)
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertTrue(
|
|
|
|
|
+ all(request[3].isoformat() == "2026-08-11" for request in cost_requests)
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(len(created_clients), 2)
|
|
|
|
|
+ self.assertTrue(all(client.session.closed for client in created_clients))
|
|
|
|
|
+
|
|
|
|
|
+ def test_read_only_report_marks_partial_at_threshold_for_manual_review(self):
|
|
|
|
|
+ from report_rejected_creatives_once import collect_rejected_creatives
|
|
|
|
|
+
|
|
|
|
|
+ tencent = Mock()
|
|
|
|
|
+ tencent.get_dynamic_creatives.return_value = [{
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "dynamic_creative_name": "创意三",
|
|
|
|
|
+ "adgroup_id": 13,
|
|
|
|
|
+ "configured_status": "AD_STATUS_NORMAL",
|
|
|
|
|
+ "creative_set_approval_status": (
|
|
|
|
|
+ "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
|
|
|
|
|
+ ),
|
|
|
|
|
+ }]
|
|
|
|
|
+ tencent.get_ads.return_value = [{"adgroup_id": 13, "adgroup_name": "广告三"}]
|
|
|
|
|
+ tencent.get_dynamic_creative_costs.return_value = {3: 3000}
|
|
|
|
|
+
|
|
|
|
|
+ rows, errors, scanned = collect_rejected_creatives(
|
|
|
|
|
+ accounts=[{"account_id": 1}],
|
|
|
|
|
+ agency_context={
|
|
|
|
|
+ "creative_agencies": {},
|
|
|
|
|
+ "account_agencies": {},
|
|
|
|
|
+ "account_names": {},
|
|
|
|
|
+ },
|
|
|
|
|
+ tencent=tencent,
|
|
|
|
|
+ review_fetcher=lambda _account, _ids: [],
|
|
|
|
|
+ checked_at=datetime(2026, 8, 12, 11, 0),
|
|
|
|
|
+ max_workers=1,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(errors, [])
|
|
|
|
|
+ self.assertEqual(scanned, 1)
|
|
|
|
|
+ self.assertEqual(len(rows), 1)
|
|
|
|
|
+ self.assertEqual(rows[0]["近3天历史消耗(元)"], "30.00")
|
|
|
|
|
+ self.assertEqual(rows[0]["消耗日期范围"], "2026-08-09 ~ 2026-08-11")
|
|
|
|
|
+ self.assertEqual(rows[0]["执行操作"], "删除创意")
|
|
|
|
|
+
|
|
|
|
|
+ def test_read_only_workbook_writes_cost_and_action_columns(self):
|
|
|
|
|
+ from report_rejected_creatives_once import REPORT_COLUMNS, write_workbook
|
|
|
|
|
+
|
|
|
|
|
+ row = {column: "" for column in REPORT_COLUMNS}
|
|
|
|
|
+ row.update(
|
|
|
|
|
+ {
|
|
|
|
|
+ "代理名称": "棱镜",
|
|
|
|
|
+ "账户ID": 1000000000001,
|
|
|
|
|
+ "广告ID": 2000000000002,
|
|
|
|
|
+ "创意ID": 3000000000003,
|
|
|
|
|
+ "近3天历史消耗(元)": "29.99",
|
|
|
|
|
+ "消耗日期范围": "2026-08-10 ~ 2026-08-12",
|
|
|
|
|
+ "执行操作": "删除创意",
|
|
|
|
|
+ "操作判断原因": "近3天实时消耗低于30元",
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
+ path = Path(directory) / "read-only.xlsx"
|
|
|
|
|
+ write_workbook([row], path)
|
|
|
|
|
+ sheet = load_workbook(path)["审核不通过创意"]
|
|
|
|
|
+
|
|
|
|
|
+ headers = [cell.value for cell in sheet[1]]
|
|
|
|
|
+ self.assertEqual(headers, list(REPORT_COLUMNS))
|
|
|
|
|
+ columns = {cell.value: cell.column for cell in sheet[1]}
|
|
|
|
|
+ self.assertEqual(sheet.cell(2, columns["近3天历史消耗(元)"]).value, "29.99")
|
|
|
|
|
+ self.assertEqual(sheet.cell(2, columns["执行操作"]).value, "删除创意")
|
|
|
|
|
+ for column_name in ("账户ID", "广告ID", "创意ID"):
|
|
|
|
|
+ cell = sheet.cell(2, columns[column_name])
|
|
|
|
|
+ self.assertEqual(cell.data_type, "s")
|
|
|
|
|
+ self.assertEqual(cell.number_format, "@")
|
|
|
|
|
+
|
|
|
|
|
+ def test_account_scope_uses_recent_three_day_opengid_spend(self):
|
|
|
|
|
+ from roi_control.data_source import (
|
|
|
|
|
+ build_recent_spend_accounts_sql,
|
|
|
|
|
+ fetch_recent_spend_accounts,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ sql = build_recent_spend_accounts_sql("20260808", "20260810")
|
|
|
|
|
+ self.assertIn("FROM loghubods.opengid_base_data", sql)
|
|
|
|
|
+ self.assertIn("dt BETWEEN '20260808' AND '20260810'", sql)
|
|
|
|
|
+ self.assertNotIn("usersharedepth", sql)
|
|
|
|
|
+ self.assertNotIn("videoid", sql)
|
|
|
|
|
+ self.assertNotIn("hotsencetype", sql)
|
|
|
|
|
+ self.assertIn("GROUP BY 账号id", sql)
|
|
|
|
|
+ self.assertIn("HAVING SUM(NVL(成本, 0)) > 0", sql)
|
|
|
|
|
+ self.assertNotIn("account_whitelist", sql)
|
|
|
|
|
+
|
|
|
|
|
+ client = Mock()
|
|
|
|
|
+ client.execute_sql.return_value = pd.DataFrame(
|
|
|
|
|
+ [
|
|
|
|
|
+ {"account_id": "1", "account_name": "账户一", "cost_yuan": 10},
|
|
|
|
|
+ {"account_id": "", "account_name": "无效", "cost_yuan": 20},
|
|
|
|
|
+ {"account_id": "2", "account_name": "账户二", "cost_yuan": 30},
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ fetch_recent_spend_accounts(client, "20260808", "20260810"),
|
|
|
|
|
+ [
|
|
|
|
|
+ {"account_id": 1, "account_name": "账户一", "cost_yuan": 10.0},
|
|
|
|
|
+ {"account_id": 2, "account_name": "账户二", "cost_yuan": 30.0},
|
|
|
|
|
+ ],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def test_account_agency_fallback_uses_latest_active_account_record(self):
|
|
|
|
|
+ from roi_control.data_source import (
|
|
|
|
|
+ build_account_agency_fallback_sql,
|
|
|
|
|
+ fetch_account_agency_fallbacks,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ sql = build_account_agency_fallback_sql([2, 1, 2])
|
|
|
|
|
+ self.assertIn("FROM loghubods.ad_put_tencent_account", sql)
|
|
|
|
|
+ self.assertIn("account_id IN ('1', '2')", sql)
|
|
|
|
|
+ self.assertIn("NVL(is_delete, 0) = 0", sql)
|
|
|
|
|
+ self.assertIn("status = 1", sql)
|
|
|
|
|
+ self.assertIn("TRIM(agent_name) <> ''", sql)
|
|
|
|
|
+ self.assertIn("ORDER BY id DESC", sql)
|
|
|
|
|
+ self.assertIn("WHERE row_number = 1", sql)
|
|
|
|
|
+ inner_projection = sql.split("FROM (", 1)[1].split(
|
|
|
|
|
+ "FROM loghubods.ad_put_tencent_account", 1
|
|
|
|
|
+ )[0]
|
|
|
|
|
+ self.assertIn("is_delete", inner_projection)
|
|
|
|
|
+ self.assertIn("status", inner_projection)
|
|
|
|
|
+
|
|
|
|
|
+ client = Mock()
|
|
|
|
|
+ client.execute_sql.return_value = pd.DataFrame(
|
|
|
|
|
+ [
|
|
|
|
|
+ {"account_id": "1", "agent_name": " 代理 A "},
|
|
|
|
|
+ {"account_id": "2", "agent_name": float("nan")},
|
|
|
|
|
+ {"account_id": "invalid", "agent_name": "代理 B"},
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(fetch_account_agency_fallbacks(client, [1, 2]), {1: "代理A"})
|
|
|
|
|
+
|
|
|
|
|
+ def test_agency_resolution_only_uses_account_table_as_last_fallback(self):
|
|
|
|
|
+ from tools.creative_rejection_cleanup import _resolve_agency
|
|
|
|
|
+
|
|
|
|
|
+ context = {
|
|
|
|
|
+ "creative_agencies": {(1, 101): "创意代理"},
|
|
|
|
|
+ "account_agencies": {1: "账户代理"},
|
|
|
|
|
+ "fallback_account_agencies": {1: "表中代理", 2: "兜底代理"},
|
|
|
|
|
+ "account_names": {},
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(_resolve_agency(context, 1, 101), "创意代理")
|
|
|
|
|
+ self.assertEqual(_resolve_agency(context, 1, 102), "账户代理")
|
|
|
|
|
+ self.assertEqual(_resolve_agency(context, 2, 201), "兜底代理")
|
|
|
|
|
+
|
|
|
|
|
+ def test_cleanup_requires_current_rejection_confirmation(self):
|
|
|
|
|
+ from tools.creative_rejection_cleanup import (
|
|
|
|
|
+ DELETE_CREATIVE,
|
|
|
|
|
+ cleanup_precondition_failure,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ item = {
|
|
|
|
|
+ "account_id": 1,
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "cleanup_status": "DISCOVERED",
|
|
|
|
|
+ "cleanup_action": DELETE_CREATIVE,
|
|
|
|
|
+ }
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ cleanup_precondition_failure(item, set(), set())[0],
|
|
|
|
|
+ "DEFERRED",
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ cleanup_precondition_failure(item, {1}, {})[0],
|
|
|
|
|
+ "SKIPPED_REVIEW_NOT_RECONFIRMED",
|
|
|
|
|
+ )
|
|
|
|
|
+ confirmed = {
|
|
|
|
|
+ (1, 3): {
|
|
|
|
|
+ "cleanup_action": DELETE_CREATIVE,
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ self.assertIsNone(cleanup_precondition_failure(item, {1}, confirmed))
|
|
|
|
|
+ item["cleanup_action"] = "DELETE_COMPONENTS"
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ cleanup_precondition_failure(item, {1}, confirmed)[0],
|
|
|
|
|
+ "SKIPPED_REVIEW_NOT_RECONFIRMED",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def test_cleanup_action_follows_creative_approval_and_element_statuses(self):
|
|
|
|
|
+ from tools.creative_rejection_cleanup import (
|
|
|
|
|
+ ALERT_ONLY,
|
|
|
|
|
+ DELETE_CREATIVE,
|
|
|
|
|
+ determine_cleanup_action,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ denied_element = {
|
|
|
|
|
+ "element_result_list": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "element_id": 101,
|
|
|
|
|
+ "review_status": "AD_STATUS_DENIED",
|
|
|
|
|
+ "component_info": {"component_id": 201},
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+ }
|
|
|
|
|
+ denied_action = determine_cleanup_action(
|
|
|
|
|
+ {
|
|
|
|
|
+ "configured_status": "AD_STATUS_NORMAL",
|
|
|
|
|
+ "creative_set_approval_status": (
|
|
|
|
|
+ "CREATIVE_SET_APPROVAL_STATUS_DENIED"
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ denied_element,
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(denied_action["cleanup_action"], DELETE_CREATIVE)
|
|
|
|
|
+ self.assertEqual(denied_action["component_ids"], [])
|
|
|
|
|
+ normal_action = determine_cleanup_action(
|
|
|
|
|
+ {
|
|
|
|
|
+ "configured_status": "AD_STATUS_SUSPEND",
|
|
|
|
|
+ "creative_set_approval_status": (
|
|
|
|
|
+ "CREATIVE_SET_APPROVAL_STATUS_NORMAL"
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ denied_element,
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertIsNone(normal_action)
|
|
|
|
|
+
|
|
|
|
|
+ partial = {
|
|
|
|
|
+ "creative_set_approval_status": (
|
|
|
|
|
+ "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ low_cost = determine_cleanup_action(
|
|
|
|
|
+ partial,
|
|
|
|
|
+ denied_element,
|
|
|
|
|
+ recent_cost_fen=4999,
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(low_cost["cleanup_action"], DELETE_CREATIVE)
|
|
|
|
|
+ at_threshold = determine_cleanup_action(
|
|
|
|
|
+ partial,
|
|
|
|
|
+ denied_element,
|
|
|
|
|
+ recent_cost_fen=5000,
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(at_threshold["cleanup_action"], ALERT_ONLY)
|
|
|
|
|
+
|
|
|
|
|
+ def test_partial_creative_with_rejected_wechat_mini_program_is_deleted(self):
|
|
|
|
|
+ from tools.creative_rejection_cleanup import (
|
|
|
|
|
+ DELETE_CREATIVE,
|
|
|
|
|
+ determine_cleanup_action,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ action = determine_cleanup_action(
|
|
|
|
|
+ {
|
|
|
|
|
+ "creative_set_approval_status": (
|
|
|
|
|
+ "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "element_result_list": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "element_name": "微信小程序",
|
|
|
|
|
+ "element_id": -8101,
|
|
|
|
|
+ "review_status": "AD_STATUS_DENIED",
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+ },
|
|
|
|
|
+ recent_cost_fen=9999,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(action["cleanup_action"], DELETE_CREATIVE)
|
|
|
|
|
+ self.assertIn("微信小程序元素审核拒绝", action["action_reason"])
|
|
|
|
|
+
|
|
|
|
|
+ def test_partial_creative_with_high_cost_rejected_wechat_requires_review(self):
|
|
|
|
|
+ from tools.creative_rejection_cleanup import ALERT_ONLY, determine_cleanup_action
|
|
|
|
|
+
|
|
|
|
|
+ action = determine_cleanup_action(
|
|
|
|
|
+ {
|
|
|
|
|
+ "creative_set_approval_status": (
|
|
|
|
|
+ "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "element_result_list": [{
|
|
|
|
|
+ "element_name": "微信小程序",
|
|
|
|
|
+ "review_status": "AD_STATUS_DENIED",
|
|
|
|
|
+ }]
|
|
|
|
|
+ },
|
|
|
|
|
+ recent_cost_fen=10001,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(action["cleanup_action"], ALERT_ONLY)
|
|
|
|
|
+ self.assertIn("需人工判断", action["action_reason"])
|
|
|
|
|
+
|
|
|
|
|
+ def test_partial_creative_spend_error_only_alerts(self):
|
|
|
|
|
+ from tools.creative_rejection_cleanup import ALERT_ONLY, determine_cleanup_action
|
|
|
|
|
+
|
|
|
|
|
+ action = determine_cleanup_action(
|
|
|
|
|
+ {
|
|
|
|
|
+ "creative_set_approval_status": (
|
|
|
|
|
+ "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ {},
|
|
|
|
|
+ spend_error="timeout",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(action["cleanup_action"], ALERT_ONLY)
|
|
|
|
|
+ self.assertIn("消耗读取失败", action["action_reason"])
|
|
|
|
|
+
|
|
|
|
|
+ def test_cleanup_candidate_insert_has_one_value_per_column(self):
|
|
|
|
|
+ from tools import creative_rejection_cleanup as cleanup
|
|
|
|
|
+
|
|
|
|
|
+ class Cursor:
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.calls = []
|
|
|
|
|
+
|
|
|
|
|
+ def __enter__(self):
|
|
|
|
|
+ return self
|
|
|
|
|
+
|
|
|
|
|
+ def __exit__(self, *_args):
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ def execute(self, sql, params=None):
|
|
|
|
|
+ self.calls.append((sql, params))
|
|
|
|
|
+
|
|
|
|
|
+ def fetchone(self):
|
|
|
|
|
+ return {"id": 7}
|
|
|
|
|
+
|
|
|
|
|
+ cursor = Cursor()
|
|
|
|
|
+ connection = Mock()
|
|
|
|
|
+ connection.cursor.return_value = cursor
|
|
|
|
|
+ with patch.object(cleanup, "get_connection", return_value=connection):
|
|
|
|
|
+ result = cleanup.upsert_cleanup_candidate(
|
|
|
|
|
+ {
|
|
|
|
|
+ "account_id": 1,
|
|
|
|
|
+ "account_name": "账户一",
|
|
|
|
|
+ "agency_name": "代理一",
|
|
|
|
|
+ "adgroup_id": 2,
|
|
|
|
|
+ "adgroup_name": "广告二",
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "dynamic_creative_name": "创意三",
|
|
|
|
|
+ "check_date": "2026-08-12",
|
|
|
|
|
+ "cleanup_action": cleanup.ALERT_ONLY,
|
|
|
|
|
+ "component_ids": [],
|
|
|
|
|
+ "element_ids": [],
|
|
|
|
|
+ "recent_cost_fen": 3000,
|
|
|
|
|
+ "cost_start_date": "2026-08-10",
|
|
|
|
|
+ "cost_end_date": "2026-08-12",
|
|
|
|
|
+ "reject_reason": "待人工判断",
|
|
|
|
|
+ "review_result": {},
|
|
|
|
|
+ "pre_state": {},
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ insert_sql, insert_params = cursor.calls[0]
|
|
|
|
|
+ values_sql = insert_sql.split("ON DUPLICATE KEY UPDATE", 1)[0]
|
|
|
|
|
+ self.assertEqual(values_sql.count("%s"), 19)
|
|
|
|
|
+ self.assertEqual(len(insert_params), 19)
|
|
|
|
|
+ self.assertEqual(insert_params[-1], "ALERT_PENDING")
|
|
|
|
|
+ self.assertIn(
|
|
|
|
|
+ "NOT (recent_cost_fen <=> VALUES(recent_cost_fen))",
|
|
|
|
|
+ insert_sql,
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(result, {"id": 7})
|
|
|
|
|
+ connection.close.assert_called_once()
|
|
|
|
|
+
|
|
|
|
|
+ def test_retry_query_only_loads_whole_creative_deletes(self):
|
|
|
|
|
+ from tools import creative_rejection_cleanup as cleanup
|
|
|
|
|
+
|
|
|
|
|
+ cursor = Mock()
|
|
|
|
|
+ cursor.__enter__ = Mock(return_value=cursor)
|
|
|
|
|
+ cursor.__exit__ = Mock(return_value=None)
|
|
|
|
|
+ cursor.fetchall.return_value = []
|
|
|
|
|
+ connection = Mock()
|
|
|
|
|
+ connection.cursor.return_value = cursor
|
|
|
|
|
+
|
|
|
|
|
+ with patch.object(cleanup, "get_connection", return_value=connection):
|
|
|
|
|
+ self.assertEqual(cleanup.load_retryable_cleanup_items(), [])
|
|
|
|
|
+
|
|
|
|
|
+ sql = cursor.execute.call_args.args[0]
|
|
|
|
|
+ self.assertIn("cleanup_action='DELETE_CREATIVE'", sql)
|
|
|
|
|
+ self.assertNotIn("COMPONENTS_PARTIAL", sql)
|
|
|
|
|
+
|
|
|
|
|
+ def test_cleanup_action_does_not_delete_for_overall_or_site_denial(self):
|
|
|
|
|
+ from tools.creative_rejection_cleanup import determine_cleanup_action
|
|
|
|
|
+
|
|
|
|
|
+ creative = {
|
|
|
|
|
+ "configured_status": "AD_STATUS_NORMAL",
|
|
|
|
|
+ "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
|
|
|
|
|
+ }
|
|
|
|
|
+ result = {
|
|
|
|
|
+ "site_set_result_list": [
|
|
|
|
|
+ {"system_status": "DYNAMIC_CREATIVE_STATUS_DENIED"}
|
|
|
|
|
+ ]
|
|
|
|
|
+ }
|
|
|
|
|
+ self.assertIsNone(determine_cleanup_action(creative, result))
|
|
|
|
|
+
|
|
|
|
|
+ def test_denied_element_is_not_a_cleanup_candidate(self):
|
|
|
|
|
+ from tools.creative_rejection_cleanup import determine_cleanup_action
|
|
|
|
|
+
|
|
|
|
|
+ action = determine_cleanup_action(
|
|
|
|
|
+ {"configured_status": "AD_STATUS_NORMAL"},
|
|
|
|
|
+ {
|
|
|
|
|
+ "element_result_list": [
|
|
|
|
|
+ {"element_id": 101, "system_status": "AD_STATUS_DENIED"}
|
|
|
|
|
+ ]
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertIsNone(action)
|
|
|
|
|
+
|
|
|
|
|
+ def test_read_only_report_skips_non_rule_element_denial(self):
|
|
|
|
|
+ from report_rejected_creatives_once import collect_rejected_creatives
|
|
|
|
|
+
|
|
|
|
|
+ tencent = Mock()
|
|
|
|
|
+ tencent.get_dynamic_creatives.return_value = [{
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "adgroup_id": 2,
|
|
|
|
|
+ "configured_status": "AD_STATUS_NORMAL",
|
|
|
|
|
+ "creative_set_approval_status": "CREATIVE_SET_APPROVAL_STATUS_NORMAL",
|
|
|
|
|
+ }]
|
|
|
|
|
+ tencent.get_ads.return_value = [{"adgroup_id": 2}]
|
|
|
|
|
+ review = {
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "element_result_list": [{
|
|
|
|
|
+ "element_name": "图片",
|
|
|
|
|
+ "review_status": "AD_STATUS_DENIED",
|
|
|
|
|
+ }],
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ rows, errors, _ = collect_rejected_creatives(
|
|
|
|
|
+ accounts=[{"account_id": 1}],
|
|
|
|
|
+ agency_context={
|
|
|
|
|
+ "creative_agencies": {},
|
|
|
|
|
+ "account_agencies": {},
|
|
|
|
|
+ "account_names": {},
|
|
|
|
|
+ },
|
|
|
|
|
+ tencent=tencent,
|
|
|
|
|
+ review_fetcher=lambda _account, _ids: [review],
|
|
|
|
|
+ checked_at=datetime(2026, 8, 12, 11, 0),
|
|
|
|
|
+ max_workers=1,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(errors, [])
|
|
|
|
|
+ self.assertEqual(rows, [])
|
|
|
|
|
+
|
|
|
|
|
+ def test_tencent_client_uses_official_creative_delete_endpoint(self):
|
|
|
|
|
+ from tencent_client import TencentClient
|
|
|
|
|
+
|
|
|
|
|
+ response = Mock(status_code=200, text="ok")
|
|
|
|
|
+ response.raise_for_status.return_value = None
|
|
|
|
|
+ response.json.return_value = {"code": 0, "data": {}}
|
|
|
|
|
+ client = TencentClient()
|
|
|
|
|
+ client.session.post = Mock(return_value=response)
|
|
|
|
|
+ client._common_params = Mock(return_value={"nonce": "1"})
|
|
|
|
|
+ client._user_token = Mock(return_value="user-token")
|
|
|
|
|
+ client.get_dynamic_creative = Mock(
|
|
|
|
|
+ side_effect=RuntimeError("Dynamic creative not found: account=1 creative=3")
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ result = client.delete_dynamic_creative(1, 3)
|
|
|
|
|
+
|
|
|
|
|
+ request = client.session.post.call_args
|
|
|
|
|
+ self.assertTrue(request.args[0].endswith("/dynamic_creatives/delete"))
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ request.kwargs["json"],
|
|
|
|
|
+ {"account_id": 1, "dynamic_creative_id": 3},
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(result["configured_status"], "AD_STATUS_DELETED")
|
|
|
|
|
+
|
|
|
|
|
+ def test_tencent_client_uses_component_id_for_component_delete(self):
|
|
|
|
|
+ from tencent_client import TencentClient
|
|
|
|
|
+
|
|
|
|
|
+ response = Mock(status_code=200, text="ok")
|
|
|
|
|
+ response.raise_for_status.return_value = None
|
|
|
|
|
+ response.json.return_value = {"code": 0, "data": {}}
|
|
|
|
|
+ client = TencentClient()
|
|
|
|
|
+ client.session.post = Mock(return_value=response)
|
|
|
|
|
+ client._common_params = Mock(return_value={"nonce": "1"})
|
|
|
|
|
+ client.get_creative_component = Mock(
|
|
|
|
|
+ return_value={"component_id": 201, "is_deleted": True}
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ result = client.delete_creative_component(1, 201)
|
|
|
|
|
+
|
|
|
|
|
+ request = client.session.post.call_args
|
|
|
|
|
+ self.assertTrue(request.args[0].endswith("/components/delete"))
|
|
|
|
|
+ self.assertEqual(request.kwargs["json"]["component_id"], 201)
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ request.kwargs["json"]["delete_strategy"],
|
|
|
|
|
+ "DELETE_STRATEGY_FORCE",
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertTrue(result["is_deleted"])
|
|
|
|
|
+
|
|
|
|
|
+ def test_agency_context_prefers_creative_and_rejects_ambiguous_fallback(self):
|
|
|
|
|
+ from tools.creative_rejection_cleanup import build_agency_context
|
|
|
|
|
+
|
|
|
|
|
+ daily = pd.DataFrame(
|
|
|
|
|
+ [
|
|
|
|
|
+ {
|
|
|
|
|
+ "entity_type": "self",
|
|
|
|
|
+ "账号id": "1",
|
|
|
|
|
+ "账号名称": "账户一",
|
|
|
|
|
+ "创意id": "101",
|
|
|
|
|
+ "代理名称": "小程序-代投-棱镜",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "entity_type": "self",
|
|
|
|
|
+ "账号id": "2",
|
|
|
|
|
+ "账号名称": "账户二",
|
|
|
|
|
+ "创意id": "201",
|
|
|
|
|
+ "代理名称": "代理A",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "entity_type": "self",
|
|
|
|
|
+ "账号id": "2",
|
|
|
|
|
+ "账号名称": "账户二",
|
|
|
|
|
+ "创意id": "202",
|
|
|
|
|
+ "代理名称": "代理B",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "entity_type": "self",
|
|
|
|
|
+ "账号id": "3",
|
|
|
|
|
+ "账号名称": "账户三",
|
|
|
|
|
+ "创意id": "301",
|
|
|
|
|
+ "代理名称": float("nan"),
|
|
|
|
|
+ },
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+ context = build_agency_context(daily)
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ context["creative_agencies"][(1, 101)],
|
|
|
|
|
+ "小程序-代投-棱镜",
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(context["account_agencies"][1], "小程序-代投-棱镜")
|
|
|
|
|
+ self.assertNotIn(2, context["account_agencies"])
|
|
|
|
|
+ self.assertNotIn(3, context["account_agencies"])
|
|
|
|
|
+
|
|
|
|
|
+ def test_agency_context_uses_latest_date_regardless_of_row_order(self):
|
|
|
|
|
+ from tools.creative_rejection_cleanup import build_agency_context
|
|
|
|
|
+
|
|
|
|
|
+ rows = [
|
|
|
|
|
+ {
|
|
|
|
|
+ "entity_type": "self",
|
|
|
|
|
+ "dt": "20260810",
|
|
|
|
|
+ "账号id": "1",
|
|
|
|
|
+ "创意id": "101",
|
|
|
|
|
+ "代理名称": "旧代理",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "entity_type": "self",
|
|
|
|
|
+ "dt": "20260812",
|
|
|
|
|
+ "账号id": "1",
|
|
|
|
|
+ "创意id": "101",
|
|
|
|
|
+ "代理名称": "新代理",
|
|
|
|
|
+ },
|
|
|
|
|
+ ]
|
|
|
|
|
+ for ordered_rows in (rows, list(reversed(rows))):
|
|
|
|
|
+ context = build_agency_context(pd.DataFrame(ordered_rows))
|
|
|
|
|
+ self.assertEqual(context["creative_agencies"][(1, 101)], "新代理")
|
|
|
|
|
+ self.assertEqual(context["account_agencies"][1], "新代理")
|
|
|
|
|
+
|
|
|
|
|
+ def test_agency_context_rejects_conflicting_agencies_on_latest_date(self):
|
|
|
|
|
+ from tools.creative_rejection_cleanup import build_agency_context
|
|
|
|
|
+
|
|
|
|
|
+ context = build_agency_context(pd.DataFrame([
|
|
|
|
|
+ {
|
|
|
|
|
+ "entity_type": "self",
|
|
|
|
|
+ "dt": "20260812",
|
|
|
|
|
+ "账号id": "1",
|
|
|
|
|
+ "创意id": "101",
|
|
|
|
|
+ "代理名称": "代理A",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "entity_type": "self",
|
|
|
|
|
+ "dt": "20260812",
|
|
|
|
|
+ "账号id": "1",
|
|
|
|
|
+ "创意id": "101",
|
|
|
|
|
+ "代理名称": "代理B",
|
|
|
|
|
+ },
|
|
|
|
|
+ ]))
|
|
|
|
|
+
|
|
|
|
|
+ self.assertNotIn((1, 101), context["creative_agencies"])
|
|
|
|
|
+ self.assertNotIn(1, context["account_agencies"])
|
|
|
|
|
+
|
|
|
|
|
+ def test_agency_report_run_id_is_independent_per_destination(self):
|
|
|
|
|
+ from tools.creative_rejection_cleanup import write_cleanup_reports
|
|
|
|
|
+
|
|
|
|
|
+ def row(item_id, agency):
|
|
|
|
|
+ return {
|
|
|
|
|
+ "id": item_id,
|
|
|
|
|
+ "agency_name": agency,
|
|
|
|
|
+ "account_id": item_id,
|
|
|
|
|
+ "adgroup_id": item_id + 10,
|
|
|
|
|
+ "dynamic_creative_id": item_id + 20,
|
|
|
|
|
+ "cleanup_action": "ALERT_ONLY",
|
|
|
|
|
+ "cleanup_status": "ALERT_PENDING",
|
|
|
|
|
+ "reject_reason": "需人工判断",
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
+ _, one_report, _ = write_cleanup_reports(
|
|
|
|
|
+ [row(1, "代理A")], Path(directory), "20260812"
|
|
|
|
|
+ )
|
|
|
|
|
+ _, two_reports, _ = write_cleanup_reports(
|
|
|
|
|
+ [row(1, "代理A"), row(2, "代理B")],
|
|
|
|
|
+ Path(directory),
|
|
|
|
|
+ "20260812",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ run_id_a = next(
|
|
|
|
|
+ report["run_id"]
|
|
|
|
|
+ for report in two_reports
|
|
|
|
|
+ if report["agency_name"] == "代理A"
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(one_report[0]["run_id"], run_id_a)
|
|
|
|
|
+
|
|
|
|
|
+ def test_daily_cleanup_schema_uses_check_date_in_unique_key(self):
|
|
|
|
|
+ schema = (
|
|
|
|
|
+ Path(__file__).parents[1]
|
|
|
|
|
+ / "tencent_realtime_control"
|
|
|
|
|
+ / "schema.sql"
|
|
|
|
|
+ ).read_text(encoding="utf-8")
|
|
|
|
|
+ self.assertIn(
|
|
|
|
|
+ "(account_id, dynamic_creative_id, check_date)",
|
|
|
|
|
+ schema,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def test_cleanup_report_has_required_business_columns(self):
|
|
|
|
|
+ from tools.creative_rejection_cleanup import (
|
|
|
|
|
+ REPORT_COLUMNS,
|
|
|
|
|
+ write_cleanup_reports,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ now = datetime(2026, 8, 11, 11, 0)
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
+ _, reports, item_ids = write_cleanup_reports(
|
|
|
|
|
+ [
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": 7,
|
|
|
|
|
+ "agency_name": "棱镜",
|
|
|
|
|
+ "account_id": 1000000000001,
|
|
|
|
|
+ "account_name": "账户一",
|
|
|
|
|
+ "adgroup_id": 2000000000002,
|
|
|
|
|
+ "adgroup_name": "广告二",
|
|
|
|
|
+ "dynamic_creative_id": 3000000000003,
|
|
|
|
|
+ "dynamic_creative_name": "创意三",
|
|
|
|
|
+ "cleanup_action": "DELETE_CREATIVE",
|
|
|
|
|
+ "target_element_ids_json": "[]",
|
|
|
|
|
+ "target_component_ids_json": "[]",
|
|
|
|
|
+ "cleanup_status": "CREATIVE_DELETED",
|
|
|
|
|
+ "recent_cost_fen": 54261,
|
|
|
|
|
+ "cost_start_date": "2026-08-08",
|
|
|
|
|
+ "cost_end_date": "2026-08-10",
|
|
|
|
|
+ "action_reason": "创意审核状态为审核拒绝",
|
|
|
|
|
+ "reject_reason": "图片违规",
|
|
|
|
|
+ "review_result_json": json.dumps(
|
|
|
|
|
+ {
|
|
|
|
|
+ "element_result_list": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "element_name": "主图",
|
|
|
|
|
+ "image_id": "10001",
|
|
|
|
|
+ "review_status": "REVIEW_STATUS_REJECTED",
|
|
|
|
|
+ "element_reject_detail_info": [
|
|
|
|
|
+ {"reason": "图片违规"}
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "element_name": "正常元素",
|
|
|
|
|
+ "element_id": "30003",
|
|
|
|
|
+ "system_status": "AD_STATUS_NORMAL",
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ "site_set_result_list": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "site_set": "SITE_SET_MOMENTS",
|
|
|
|
|
+ "site_set_id": "40004",
|
|
|
|
|
+ "system_status": (
|
|
|
|
|
+ "DYNAMIC_CREATIVE_STATUS_DENIED"
|
|
|
|
|
+ ),
|
|
|
|
|
+ "reject_message": "朋友圈版位拒绝",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "site_set": "SITE_SET_NORMAL",
|
|
|
|
|
+ "review_status": "AD_STATUS_NORMAL",
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ ensure_ascii=False,
|
|
|
|
|
+ ),
|
|
|
|
|
+ "pre_state_json": json.dumps(
|
|
|
|
|
+ {
|
|
|
|
|
+ "configured_status": "AD_STATUS_NORMAL",
|
|
|
|
|
+ "creative_set_approval_status": (
|
|
|
|
|
+ "CREATIVE_SET_APPROVAL_STATUS_DENIED"
|
|
|
|
|
+ ),
|
|
|
|
|
+ }
|
|
|
|
|
+ ),
|
|
|
|
|
+ "deleted_at": now,
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ Path(directory),
|
|
|
|
|
+ "20260811",
|
|
|
|
|
+ )
|
|
|
|
|
+ workbook = load_workbook(reports[0]["report"])
|
|
|
|
|
+ sheet = workbook["审核不通过创意清理"]
|
|
|
|
|
+
|
|
|
|
|
+ headers = [cell.value for cell in sheet[1]]
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ headers,
|
|
|
|
|
+ [
|
|
|
|
|
+ "代理名称",
|
|
|
|
|
+ "账户ID",
|
|
|
|
|
+ "账户名称",
|
|
|
|
|
+ "广告ID",
|
|
|
|
|
+ "广告名称",
|
|
|
|
|
+ "创意ID",
|
|
|
|
|
+ "创意名称",
|
|
|
|
|
+ "配置状态",
|
|
|
|
|
+ "创意审核状态",
|
|
|
|
|
+ "元素粒度审核状态",
|
|
|
|
|
+ "元素粒度审核不通过原因",
|
|
|
|
|
+ "版位粒度审核状态",
|
|
|
|
|
+ "版位粒度审核不通过原因",
|
|
|
|
|
+ "审核不通过原因",
|
|
|
|
|
+ "检查时间",
|
|
|
|
|
+ "执行操作",
|
|
|
|
|
+ ],
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(headers, list(REPORT_COLUMNS))
|
|
|
|
|
+ for column_index, expected in (
|
|
|
|
|
+ (2, "1000000000001"),
|
|
|
|
|
+ (4, "2000000000002"),
|
|
|
|
|
+ (6, "3000000000003"),
|
|
|
|
|
+ ):
|
|
|
|
|
+ cell = sheet.cell(2, column_index)
|
|
|
|
|
+ self.assertEqual(cell.value, expected)
|
|
|
|
|
+ self.assertEqual(cell.data_type, "s")
|
|
|
|
|
+ self.assertEqual(cell.number_format, "@")
|
|
|
|
|
+ by_header = {cell.value: cell.column for cell in sheet[1]}
|
|
|
|
|
+ self.assertEqual(sheet.cell(2, by_header["配置状态"]).value, "投放中")
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ sheet.cell(2, by_header["创意审核状态"]).value,
|
|
|
|
|
+ "审核拒绝",
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ sheet.cell(2, by_header["元素粒度审核状态"]).value,
|
|
|
|
|
+ "主图(id=10001): 审核拒绝",
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ sheet.cell(2, by_header["元素粒度审核不通过原因"]).value,
|
|
|
|
|
+ "主图(id=10001): 图片违规",
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ sheet.cell(2, by_header["版位粒度审核状态"]).value,
|
|
|
|
|
+ "SITE_SET_MOMENTS(id=40004): 审核拒绝",
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ sheet.cell(2, by_header["版位粒度审核不通过原因"]).value,
|
|
|
|
|
+ "SITE_SET_MOMENTS(id=40004): 朋友圈版位拒绝",
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ sheet.cell(2, by_header["执行操作"]).value,
|
|
|
|
|
+ "删除创意",
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(sheet.cell(2, by_header["代理名称"]).value, "棱镜")
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ sheet.cell(2, by_header["审核不通过原因"]).value,
|
|
|
|
|
+ "图片违规",
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ sheet.cell(2, by_header["检查时间"]).value,
|
|
|
|
|
+ "2026-08-11 11:00:00",
|
|
|
|
|
+ )
|
|
|
|
|
+ for removed_header in (
|
|
|
|
|
+ "近3天历史消耗(元)",
|
|
|
|
|
+ "消耗日期范围",
|
|
|
|
|
+ "操作判断原因",
|
|
|
|
|
+ "元素ID",
|
|
|
|
|
+ "组件ID",
|
|
|
|
|
+ "处理结果",
|
|
|
|
|
+ "处理时间",
|
|
|
|
|
+ ):
|
|
|
|
|
+ self.assertNotIn(removed_header, by_header)
|
|
|
|
|
+ self.assertEqual(item_ids, {"棱镜": [7]})
|
|
|
|
|
+ self.assertEqual(reports[0]["notification_type"], "creative_rejection_cleanup")
|
|
|
|
|
+
|
|
|
|
|
+ def test_cleanup_report_displays_partial_creative_manual_alert(self):
|
|
|
|
|
+ from tools.creative_rejection_cleanup import write_cleanup_reports
|
|
|
|
|
+
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
+ _, reports, _ = write_cleanup_reports(
|
|
|
|
|
+ [
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": 8,
|
|
|
|
|
+ "agency_name": "棱镜",
|
|
|
|
|
+ "account_id": 1,
|
|
|
|
|
+ "account_name": "账户一",
|
|
|
|
|
+ "adgroup_id": 2,
|
|
|
|
|
+ "adgroup_name": "广告二",
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "dynamic_creative_name": "创意三",
|
|
|
|
|
+ "cleanup_action": "ALERT_ONLY",
|
|
|
|
|
+ "target_element_ids_json": "[]",
|
|
|
|
|
+ "target_component_ids_json": "[]",
|
|
|
|
|
+ "cleanup_status": "ALERT_PENDING",
|
|
|
|
|
+ "recent_cost_fen": 3000,
|
|
|
|
|
+ "cost_start_date": "2026-08-10",
|
|
|
|
|
+ "cost_end_date": "2026-08-12",
|
|
|
|
|
+ "reject_reason": "部分投放中,需人工判断是否删除",
|
|
|
|
|
+ "review_result_json": "{}",
|
|
|
|
|
+ "pre_state_json": json.dumps(
|
|
|
|
|
+ {
|
|
|
|
|
+ "creative_set_approval_status": (
|
|
|
|
|
+ "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ ),
|
|
|
|
|
+ "updated_at": datetime(2026, 8, 12, 11, 0),
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ Path(directory),
|
|
|
|
|
+ "20260812",
|
|
|
|
|
+ )
|
|
|
|
|
+ sheet = load_workbook(reports[0]["report"])["审核不通过创意清理"]
|
|
|
|
|
+
|
|
|
|
|
+ columns = {cell.value: cell.column for cell in sheet[1]}
|
|
|
|
|
+ self.assertEqual(sheet.cell(2, columns["执行操作"]).value, "需人工判断")
|
|
|
|
|
+ self.assertEqual(sheet.cell(2, columns["创意审核状态"]).value, "部分投放中")
|
|
|
|
|
+ self.assertNotIn("近3天历史消耗(元)", columns)
|
|
|
|
|
+ self.assertNotIn("操作判断原因", columns)
|
|
|
|
|
+
|
|
|
|
|
+ def test_operator_summary_report_contains_all_agencies(self):
|
|
|
|
|
+ from tools.creative_rejection_cleanup import (
|
|
|
|
|
+ write_cleanup_operator_summary,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ rows = [
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": index,
|
|
|
|
|
+ "agency_name": agency,
|
|
|
|
|
+ "account_id": index,
|
|
|
|
|
+ "account_name": f"账户{index}",
|
|
|
|
|
+ "adgroup_id": index + 10,
|
|
|
|
|
+ "adgroup_name": f"广告{index}",
|
|
|
|
|
+ "dynamic_creative_id": index + 20,
|
|
|
|
|
+ "dynamic_creative_name": f"创意{index}",
|
|
|
|
|
+ "cleanup_action": "DELETE_CREATIVE",
|
|
|
|
|
+ "reject_reason": "图片违规",
|
|
|
|
|
+ "review_result_json": "{}",
|
|
|
|
|
+ "pre_state_json": "{}",
|
|
|
|
|
+ "updated_at": datetime(2026, 8, 12, 11, 0),
|
|
|
|
|
+ }
|
|
|
|
|
+ for index, agency in ((1, "代理A"), (2, "代理B"), (3, ""))
|
|
|
|
|
+ ]
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
+ report = write_cleanup_operator_summary(
|
|
|
|
|
+ rows,
|
|
|
|
|
+ Path(directory),
|
|
|
|
|
+ "20260812",
|
|
|
|
|
+ "reject_20260812_abc123",
|
|
|
|
|
+ )
|
|
|
|
|
+ sheet = load_workbook(report["report"])["审核不通过创意清理"]
|
|
|
|
|
+
|
|
|
|
|
+ agencies = [sheet.cell(row, 1).value or "" for row in range(2, 5)]
|
|
|
|
|
+ self.assertEqual(agencies, ["代理A", "代理B", ""])
|
|
|
|
|
+ self.assertEqual(report["creative_rows"], 3)
|
|
|
|
|
+ self.assertIn("投放调控", Path(report["report"]).name)
|
|
|
|
|
+
|
|
|
|
|
+ def test_operator_summary_reuses_uploaded_sheet_when_notification_retries(self):
|
|
|
|
|
+ from tools import creative_rejection_cleanup as cleanup
|
|
|
|
|
+
|
|
|
|
|
+ publisher = Mock()
|
|
|
|
|
+ publisher.send_report_card.return_value = "message-1"
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
+ path = Path(directory) / "summary.xlsx"
|
|
|
|
|
+ path.write_bytes(b"xlsx")
|
|
|
|
|
+ report = {
|
|
|
|
|
+ "report_version": "summary-v1",
|
|
|
|
|
+ "report": str(path),
|
|
|
|
|
+ "title": "创意审核异常处理汇总",
|
|
|
|
|
+ "creative_rows": 3,
|
|
|
|
|
+ }
|
|
|
|
|
+ with patch.object(
|
|
|
|
|
+ cleanup,
|
|
|
|
|
+ "upsert_cleanup_delivery",
|
|
|
|
|
+ return_value={
|
|
|
|
|
+ "id": 9,
|
|
|
|
|
+ "status": "FAILED",
|
|
|
|
|
+ "sheet_url": "https://example.test/existing-summary",
|
|
|
|
|
+ "sheet_token": "existing-token",
|
|
|
|
|
+ },
|
|
|
|
|
+ ), patch.object(cleanup, "update_cleanup_delivery") as update:
|
|
|
|
|
+ outcome = cleanup.publish_cleanup_operator_summary(
|
|
|
|
|
+ run_id="reject_20260812_abc123",
|
|
|
|
|
+ report=report,
|
|
|
|
|
+ chat_id="chat-operator",
|
|
|
|
|
+ publisher=publisher,
|
|
|
|
|
+ now=datetime(2026, 8, 12, 11, 0),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ publisher.upload_workbook.assert_not_called()
|
|
|
|
|
+ publisher.send_report_card.assert_called_once_with(
|
|
|
|
|
+ title="创意审核异常处理汇总",
|
|
|
|
|
+ content="本批次共 **3** 条创意,包含各代理自动删除及需人工判断的完整汇总。",
|
|
|
|
|
+ sheet_url="https://example.test/existing-summary",
|
|
|
|
|
+ chat_id="chat-operator",
|
|
|
|
|
+ button_text="查看全部处理明细",
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(outcome["status"], "SENT")
|
|
|
|
|
+ self.assertEqual(update.call_args.kwargs["status"], "SENT")
|
|
|
|
|
+
|
|
|
|
|
+ def test_apply_deletes_denied_creative_and_marks_sent_notification(self):
|
|
|
|
|
+ from tools import creative_rejection_cleanup as cleanup
|
|
|
|
|
+
|
|
|
|
|
+ @contextmanager
|
|
|
|
|
+ def acquired_lock(_name):
|
|
|
|
|
+ yield True
|
|
|
|
|
+
|
|
|
|
|
+ class FakeTencent:
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.deleted = []
|
|
|
|
|
+ self.seeded_tokens = {}
|
|
|
|
|
+
|
|
|
|
|
+ def seed_access_tokens(self, tokens):
|
|
|
|
|
+ self.seeded_tokens.update(tokens)
|
|
|
|
|
+
|
|
|
|
|
+ def get_dynamic_creatives(self, account_id):
|
|
|
|
|
+ return [
|
|
|
|
|
+ {
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "dynamic_creative_name": "创意三",
|
|
|
|
|
+ "adgroup_id": 2,
|
|
|
|
|
+ "configured_status": "AD_STATUS_NORMAL",
|
|
|
|
|
+ "creative_set_approval_status": (
|
|
|
|
|
+ "CREATIVE_SET_APPROVAL_STATUS_DENIED"
|
|
|
|
|
+ ),
|
|
|
|
|
+ "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ def get_ads(self, account_id):
|
|
|
|
|
+ return [{"adgroup_id": 2, "adgroup_name": "广告二"}]
|
|
|
|
|
+
|
|
|
|
|
+ def get_dynamic_creative(self, account_id, creative_id):
|
|
|
|
|
+ return {
|
|
|
|
|
+ "dynamic_creative_id": creative_id,
|
|
|
|
|
+ "adgroup_id": 2,
|
|
|
|
|
+ "configured_status": "AD_STATUS_NORMAL",
|
|
|
|
|
+ "creative_set_approval_status": (
|
|
|
|
|
+ "CREATIVE_SET_APPROVAL_STATUS_DENIED"
|
|
|
|
|
+ ),
|
|
|
|
|
+ "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ def delete_dynamic_creative(self, account_id, creative_id):
|
|
|
|
|
+ self.deleted.append((account_id, creative_id))
|
|
|
|
|
+ return {
|
|
|
|
|
+ "dynamic_creative_id": creative_id,
|
|
|
|
|
+ "configured_status": "AD_STATUS_DELETED",
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ tencent = FakeTencent()
|
|
|
|
|
+ current = datetime(2026, 8, 11, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
|
|
|
|
|
+ retry_item = {
|
|
|
|
|
+ "id": 7,
|
|
|
|
|
+ "account_id": 1,
|
|
|
|
|
+ "account_name": "账户一",
|
|
|
|
|
+ "agency_name": "棱镜",
|
|
|
|
|
+ "adgroup_id": 2,
|
|
|
|
|
+ "adgroup_name": "广告二",
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "dynamic_creative_name": "创意三",
|
|
|
|
|
+ "cleanup_action": "DELETE_CREATIVE",
|
|
|
|
|
+ "target_component_ids_json": "[]",
|
|
|
|
|
+ "target_element_ids_json": "[]",
|
|
|
|
|
+ "reject_reason": "图片违规",
|
|
|
|
|
+ "cleanup_status": "DISCOVERED",
|
|
|
|
|
+ }
|
|
|
|
|
+ deleted_item = {
|
|
|
|
|
+ **retry_item,
|
|
|
|
|
+ "cleanup_status": "CREATIVE_DELETED",
|
|
|
|
|
+ "deleted_at": current,
|
|
|
|
|
+ }
|
|
|
|
|
+ updates = []
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as directory, patch.dict(
|
|
|
|
|
+ os.environ,
|
|
|
|
|
+ {
|
|
|
|
|
+ "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOK_ENABLED": "1",
|
|
|
|
|
+ "FEISHU_OPERATOR_CHAT_ID": "chat-operator",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOKS_JSON": json.dumps(
|
|
|
|
|
+ {
|
|
|
|
|
+ "棱镜": (
|
|
|
|
|
+ "https://open.feishu.cn/open-apis/bot/v2/hook/"
|
|
|
|
|
+ "test-cleanup-route"
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ clear=False,
|
|
|
|
|
+ ), patch.object(cleanup, "initialize_schema"), patch.object(
|
|
|
|
|
+ cleanup, "resolve_end_date", return_value="20260810"
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "fetch_daily_data", return_value=pd.DataFrame(
|
|
|
|
|
+ [
|
|
|
|
|
+ {
|
|
|
|
|
+ "entity_type": "self",
|
|
|
|
|
+ "账号id": "1",
|
|
|
|
|
+ "账号名称": "账户一",
|
|
|
|
|
+ "创意id": "3",
|
|
|
|
|
+ "代理名称": "棱镜",
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "fetch_account_agency_fallbacks", return_value={}
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup,
|
|
|
|
|
+ "prefetch_account_access_tokens",
|
|
|
|
|
+ return_value={1: "access-token-account-1"},
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "upsert_cleanup_candidate"
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup,
|
|
|
|
|
+ "update_cleanup_item",
|
|
|
|
|
+ side_effect=lambda item_id, **values: updates.append((item_id, values)),
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "load_unnotified_deleted_items", return_value=[deleted_item]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "advisory_lock", side_effect=acquired_lock
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup,
|
|
|
|
|
+ "publish_agency_reports",
|
|
|
|
|
+ return_value=[{"agency_name": "棱镜", "status": "SENT"}],
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup,
|
|
|
|
|
+ "publish_cleanup_operator_summary",
|
|
|
|
|
+ return_value={"route": "投放调控汇总", "status": "SENT"},
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "mark_cleanup_items_notified"
|
|
|
|
|
+ ) as mark_notified, patch.object(
|
|
|
|
|
+ cleanup, "mark_cleanup_items_operator_notified"
|
|
|
|
|
+ ) as mark_operator_notified:
|
|
|
|
|
+ summary = cleanup.run_rejected_creative_cleanup(
|
|
|
|
|
+ output_dir=Path(directory),
|
|
|
|
|
+ now=current,
|
|
|
|
|
+ tencent=tencent,
|
|
|
|
|
+ odps=Mock(),
|
|
|
|
|
+ review_fetcher=lambda _account, _ids: [
|
|
|
|
|
+ {
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "reject_message_list": ["图片违规"],
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ publisher=Mock(),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(tencent.deleted, [(1, 3)])
|
|
|
|
|
+ self.assertEqual(tencent.seeded_tokens, {1: "access-token-account-1"})
|
|
|
|
|
+ self.assertEqual(summary["account_scope"], "opengid_recent_3d_spend")
|
|
|
|
|
+ self.assertEqual(summary["account_scope_start_date"], "20260808")
|
|
|
|
|
+ self.assertEqual(summary["account_scope_end_date"], "20260810")
|
|
|
|
|
+ self.assertEqual(summary["account_ids"], [1])
|
|
|
|
|
+ self.assertEqual(summary["tokens_prefetched"], 1)
|
|
|
|
|
+ self.assertEqual(summary["deleted"], 1)
|
|
|
|
|
+ self.assertTrue(
|
|
|
|
|
+ any(
|
|
|
|
|
+ values.get("cleanup_status") == "CREATIVE_DELETED"
|
|
|
|
|
+ for _, values in updates
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ mark_notified.assert_called_once_with([7], current)
|
|
|
|
|
+ mark_operator_notified.assert_called_once_with([7], current)
|
|
|
|
|
+
|
|
|
|
|
+ def test_apply_deletes_partial_below_threshold_and_alerts_at_threshold(self):
|
|
|
|
|
+ from tools import creative_rejection_cleanup as cleanup
|
|
|
|
|
+
|
|
|
|
|
+ @contextmanager
|
|
|
|
|
+ def acquired_lock(_name):
|
|
|
|
|
+ yield True
|
|
|
|
|
+
|
|
|
|
|
+ class FakeTencent:
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.deleted = []
|
|
|
|
|
+ self.cost_requests = []
|
|
|
|
|
+
|
|
|
|
|
+ def seed_access_tokens(self, _tokens):
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ def get_dynamic_creatives(self, _account_id):
|
|
|
|
|
+ return [
|
|
|
|
|
+ {
|
|
|
|
|
+ "dynamic_creative_id": creative_id,
|
|
|
|
|
+ "dynamic_creative_name": f"创意{creative_id}",
|
|
|
|
|
+ "adgroup_id": creative_id + 10,
|
|
|
|
|
+ "configured_status": "AD_STATUS_NORMAL",
|
|
|
|
|
+ "creative_set_approval_status": (
|
|
|
|
|
+ "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
|
|
|
|
|
+ ),
|
|
|
|
|
+ }
|
|
|
|
|
+ for creative_id in (3, 4)
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ def get_ads(self, _account_id):
|
|
|
|
|
+ return [
|
|
|
|
|
+ {"adgroup_id": creative_id + 10, "adgroup_name": f"广告{creative_id}"}
|
|
|
|
|
+ for creative_id in (3, 4)
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ def get_dynamic_creative(self, _account_id, creative_id):
|
|
|
|
|
+ return {
|
|
|
|
|
+ "dynamic_creative_id": creative_id,
|
|
|
|
|
+ "adgroup_id": creative_id + 10,
|
|
|
|
|
+ "configured_status": "AD_STATUS_NORMAL",
|
|
|
|
|
+ "creative_set_approval_status": (
|
|
|
|
|
+ "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
|
|
|
|
|
+ ),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ def get_dynamic_creative_costs(
|
|
|
|
|
+ self, account_id, creative_ids, start_date, end_date
|
|
|
|
|
+ ):
|
|
|
|
|
+ self.cost_requests.append(
|
|
|
|
|
+ (account_id, list(creative_ids), start_date, end_date)
|
|
|
|
|
+ )
|
|
|
|
|
+ return {
|
|
|
|
|
+ creative_id: {3: 2999, 4: 3000}[creative_id]
|
|
|
|
|
+ for creative_id in creative_ids
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ def delete_dynamic_creative(self, account_id, creative_id):
|
|
|
|
|
+ self.deleted.append((account_id, creative_id))
|
|
|
|
|
+ return {"dynamic_creative_id": creative_id, "deleted_from_listing": True}
|
|
|
|
|
+
|
|
|
|
|
+ current = datetime(2026, 8, 12, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
|
|
|
|
|
+ retry_item = {
|
|
|
|
|
+ "id": 7,
|
|
|
|
|
+ "account_id": 1,
|
|
|
|
|
+ "agency_name": "棱镜",
|
|
|
|
|
+ "adgroup_id": 13,
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "cleanup_action": "DELETE_CREATIVE",
|
|
|
|
|
+ "target_component_ids_json": "[]",
|
|
|
|
|
+ "target_element_ids_json": "[]",
|
|
|
|
|
+ "cleanup_status": "DISCOVERED",
|
|
|
|
|
+ }
|
|
|
|
|
+ candidates = []
|
|
|
|
|
+ updates = []
|
|
|
|
|
+ tencent = FakeTencent()
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as directory, patch.dict(
|
|
|
|
|
+ os.environ,
|
|
|
|
|
+ {
|
|
|
|
|
+ "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
|
|
|
|
|
+ "DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN": "30",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOK_ENABLED": "1",
|
|
|
|
|
+ "FEISHU_OPERATOR_CHAT_ID": "chat-operator",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOKS_JSON": json.dumps(
|
|
|
|
|
+ {
|
|
|
|
|
+ "棱镜": (
|
|
|
|
|
+ "https://open.feishu.cn/open-apis/bot/v2/hook/"
|
|
|
|
|
+ "test-cleanup-route"
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ clear=False,
|
|
|
|
|
+ ), patch.object(cleanup, "initialize_schema"), patch.object(
|
|
|
|
|
+ cleanup, "resolve_end_date", return_value="20260811"
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup,
|
|
|
|
|
+ "fetch_daily_data",
|
|
|
|
|
+ return_value=pd.DataFrame(
|
|
|
|
|
+ [
|
|
|
|
|
+ {
|
|
|
|
|
+ "entity_type": "self",
|
|
|
|
|
+ "账号id": "1",
|
|
|
|
|
+ "账号名称": "账户一",
|
|
|
|
|
+ "创意id": str(creative_id),
|
|
|
|
|
+ "代理名称": "棱镜",
|
|
|
|
|
+ }
|
|
|
|
|
+ for creative_id in (3, 4)
|
|
|
|
|
+ ]
|
|
|
|
|
+ ),
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "fetch_account_agency_fallbacks", return_value={}
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "prefetch_account_access_tokens", return_value={1: "token-account-1"}
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup,
|
|
|
|
|
+ "upsert_cleanup_candidate",
|
|
|
|
|
+ side_effect=lambda record: candidates.append(record) or {"id": len(candidates)},
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup,
|
|
|
|
|
+ "update_cleanup_item",
|
|
|
|
|
+ side_effect=lambda item_id, **values: updates.append((item_id, values)),
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "load_unnotified_deleted_items", return_value=[]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "advisory_lock", side_effect=acquired_lock
|
|
|
|
|
+ ):
|
|
|
|
|
+ summary = cleanup.run_rejected_creative_cleanup(
|
|
|
|
|
+ output_dir=Path(directory),
|
|
|
|
|
+ now=current,
|
|
|
|
|
+ tencent=tencent,
|
|
|
|
|
+ odps=Mock(),
|
|
|
|
|
+ review_fetcher=lambda _account, _ids: [],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ actions = {
|
|
|
|
|
+ row["dynamic_creative_id"]: row["cleanup_action"] for row in candidates
|
|
|
|
|
+ }
|
|
|
|
|
+ self.assertEqual(actions, {3: "DELETE_CREATIVE", 4: "ALERT_ONLY"})
|
|
|
|
|
+ self.assertEqual(tencent.deleted, [(1, 3)])
|
|
|
|
|
+ self.assertEqual(summary["deleted"], 1)
|
|
|
|
|
+ self.assertEqual(len(tencent.cost_requests), 2)
|
|
|
|
|
+ for _, _, start_date, end_date in tencent.cost_requests:
|
|
|
|
|
+ self.assertEqual(start_date.isoformat(), "2026-08-09")
|
|
|
|
|
+ self.assertEqual(end_date.isoformat(), "2026-08-11")
|
|
|
|
|
+ self.assertTrue(
|
|
|
|
|
+ any(
|
|
|
|
|
+ values.get("cleanup_status") == "CREATIVE_DELETED"
|
|
|
|
|
+ for _, values in updates
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def test_lock_time_spend_failure_downgrades_delete_to_manual_alert(self):
|
|
|
|
|
+ from tools import creative_rejection_cleanup as cleanup
|
|
|
|
|
+
|
|
|
|
|
+ @contextmanager
|
|
|
|
|
+ def acquired_lock(_name):
|
|
|
|
|
+ yield True
|
|
|
|
|
+
|
|
|
|
|
+ class FakeTencent:
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.cost_call_count = 0
|
|
|
|
|
+ self.deleted = []
|
|
|
|
|
+
|
|
|
|
|
+ def seed_access_tokens(self, _tokens):
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ def get_dynamic_creatives(self, _account_id):
|
|
|
|
|
+ return [{
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "adgroup_id": 13,
|
|
|
|
|
+ "configured_status": "AD_STATUS_NORMAL",
|
|
|
|
|
+ "creative_set_approval_status": (
|
|
|
|
|
+ "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
|
|
|
|
|
+ ),
|
|
|
|
|
+ }]
|
|
|
|
|
+
|
|
|
|
|
+ def get_ads(self, _account_id):
|
|
|
|
|
+ return [{"adgroup_id": 13}]
|
|
|
|
|
+
|
|
|
|
|
+ def get_dynamic_creative(self, _account_id, _creative_id):
|
|
|
|
|
+ return self.get_dynamic_creatives(1)[0]
|
|
|
|
|
+
|
|
|
|
|
+ def get_dynamic_creative_costs(self, *_args):
|
|
|
|
|
+ self.cost_call_count += 1
|
|
|
|
|
+ if self.cost_call_count == 1:
|
|
|
|
|
+ return {3: 2999}
|
|
|
|
|
+ raise RuntimeError("cost API timeout")
|
|
|
|
|
+
|
|
|
|
|
+ def delete_dynamic_creative(self, account_id, creative_id):
|
|
|
|
|
+ self.deleted.append((account_id, creative_id))
|
|
|
|
|
+
|
|
|
|
|
+ retry_item = {
|
|
|
|
|
+ "id": 7,
|
|
|
|
|
+ "account_id": 1,
|
|
|
|
|
+ "agency_name": "棱镜",
|
|
|
|
|
+ "adgroup_id": 13,
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "cleanup_action": "DELETE_CREATIVE",
|
|
|
|
|
+ "target_component_ids_json": "[]",
|
|
|
|
|
+ "target_element_ids_json": "[]",
|
|
|
|
|
+ "cleanup_status": "DISCOVERED",
|
|
|
|
|
+ }
|
|
|
|
|
+ updates = []
|
|
|
|
|
+ tencent = FakeTencent()
|
|
|
|
|
+ current = datetime(2026, 8, 12, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as directory, patch.dict(
|
|
|
|
|
+ os.environ,
|
|
|
|
|
+ {
|
|
|
|
|
+ "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOK_ENABLED": "1",
|
|
|
|
|
+ "FEISHU_OPERATOR_CHAT_ID": "chat-operator",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOKS_JSON": json.dumps(
|
|
|
|
|
+ {
|
|
|
|
|
+ "棱镜": (
|
|
|
|
|
+ "https://open.feishu.cn/open-apis/bot/v2/hook/"
|
|
|
|
|
+ "test-cleanup-route"
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ clear=False,
|
|
|
|
|
+ ), patch.object(cleanup, "initialize_schema"), patch.object(
|
|
|
|
|
+ cleanup, "resolve_end_date", return_value="20260811"
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "fetch_daily_data", return_value=pd.DataFrame()
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "fetch_account_agency_fallbacks", return_value={}
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "prefetch_account_access_tokens", return_value={1: "token-account-1"}
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "upsert_cleanup_candidate"
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup,
|
|
|
|
|
+ "update_cleanup_item",
|
|
|
|
|
+ side_effect=lambda item_id, **values: updates.append((item_id, values)),
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "load_unnotified_deleted_items", return_value=[]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "advisory_lock", side_effect=acquired_lock
|
|
|
|
|
+ ):
|
|
|
|
|
+ summary = cleanup.run_rejected_creative_cleanup(
|
|
|
|
|
+ output_dir=Path(directory),
|
|
|
|
|
+ now=current,
|
|
|
|
|
+ tencent=tencent,
|
|
|
|
|
+ odps=Mock(),
|
|
|
|
|
+ review_fetcher=lambda _account, _ids: [],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(tencent.deleted, [])
|
|
|
|
|
+ self.assertEqual(summary["deleted"], 0)
|
|
|
|
|
+ alert_update = next(
|
|
|
|
|
+ values
|
|
|
|
|
+ for _, values in updates
|
|
|
|
|
+ if values.get("cleanup_status") == "ALERT_PENDING"
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(alert_update["cleanup_action"], "ALERT_ONLY")
|
|
|
|
|
+ self.assertIsNone(alert_update["recent_cost_fen"])
|
|
|
|
|
+ self.assertIn("消耗读取失败", alert_update["action_reason"])
|
|
|
|
|
+ self.assertEqual(alert_update["reject_reason"], "腾讯正式审核未通过")
|
|
|
|
|
+
|
|
|
|
|
+ def test_historical_component_cleanup_candidate_is_never_executed(self):
|
|
|
|
|
+ from tools import creative_rejection_cleanup as cleanup
|
|
|
|
|
+
|
|
|
|
|
+ @contextmanager
|
|
|
|
|
+ def acquired_lock(_name):
|
|
|
|
|
+ yield True
|
|
|
|
|
+
|
|
|
|
|
+ class FakeTencent:
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.deleted_creatives = []
|
|
|
|
|
+ self.deleted_components = []
|
|
|
|
|
+
|
|
|
|
|
+ def seed_access_tokens(self, _tokens):
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ def get_dynamic_creatives(self, _account_id):
|
|
|
|
|
+ return [{
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "adgroup_id": 2,
|
|
|
|
|
+ "configured_status": "AD_STATUS_NORMAL",
|
|
|
|
|
+ }]
|
|
|
|
|
+
|
|
|
|
|
+ def get_ads(self, _account_id):
|
|
|
|
|
+ return [{"adgroup_id": 2}]
|
|
|
|
|
+
|
|
|
|
|
+ def get_dynamic_creative(self, _account_id, _creative_id):
|
|
|
|
|
+ return {
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "adgroup_id": 2,
|
|
|
|
|
+ "configured_status": "AD_STATUS_NORMAL",
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ def get_creative_component(
|
|
|
|
|
+ self, _account_id, component_id, *, include_deleted=True
|
|
|
|
|
+ ):
|
|
|
|
|
+ return {"component_id": component_id, "is_deleted": False}
|
|
|
|
|
+
|
|
|
|
|
+ def delete_creative_component(self, account_id, component_id):
|
|
|
|
|
+ self.deleted_components.append((account_id, component_id))
|
|
|
|
|
+ return {"component_id": component_id, "is_deleted": True}
|
|
|
|
|
+
|
|
|
|
|
+ def delete_dynamic_creative(self, account_id, creative_id):
|
|
|
|
|
+ self.deleted_creatives.append((account_id, creative_id))
|
|
|
|
|
+
|
|
|
|
|
+ review = {
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "element_result_list": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "element_id": 102,
|
|
|
|
|
+ "review_status": "AD_STATUS_DENIED",
|
|
|
|
|
+ "component_info": {"component_id": 12},
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ }
|
|
|
|
|
+ retry_item = {
|
|
|
|
|
+ "id": 7,
|
|
|
|
|
+ "account_id": 1,
|
|
|
|
|
+ "agency_name": "",
|
|
|
|
|
+ "adgroup_id": 2,
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "cleanup_action": "DELETE_COMPONENTS",
|
|
|
|
|
+ "target_component_ids_json": "[11, 12]",
|
|
|
|
|
+ "target_element_ids_json": "[101, 102]",
|
|
|
|
|
+ "readback_json": json.dumps(
|
|
|
|
|
+ {"component_results": {"11": {"status": "DELETED"}}}
|
|
|
|
|
+ ),
|
|
|
|
|
+ "cleanup_status": "COMPONENTS_PARTIAL",
|
|
|
|
|
+ }
|
|
|
|
|
+ tencent = FakeTencent()
|
|
|
|
|
+ updates = []
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as directory, patch.dict(
|
|
|
|
|
+ os.environ,
|
|
|
|
|
+ {
|
|
|
|
|
+ "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOK_ENABLED": "1",
|
|
|
|
|
+ "FEISHU_OPERATOR_CHAT_ID": "chat-operator",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOKS_JSON": "{}",
|
|
|
|
|
+ },
|
|
|
|
|
+ clear=False,
|
|
|
|
|
+ ), patch.object(cleanup, "initialize_schema"), patch.object(
|
|
|
|
|
+ cleanup, "resolve_end_date", return_value="20260810"
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "fetch_daily_data", return_value=pd.DataFrame()
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "fetch_account_agency_fallbacks", return_value={}
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "prefetch_account_access_tokens", return_value={1: "token-account-1"}
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "upsert_cleanup_candidate"
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup,
|
|
|
|
|
+ "update_cleanup_item",
|
|
|
|
|
+ side_effect=lambda item_id, **values: updates.append((item_id, values)),
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "load_unnotified_deleted_items", return_value=[]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "advisory_lock", side_effect=acquired_lock
|
|
|
|
|
+ ):
|
|
|
|
|
+ summary = cleanup.run_rejected_creative_cleanup(
|
|
|
|
|
+ output_dir=Path(directory),
|
|
|
|
|
+ tencent=tencent,
|
|
|
|
|
+ odps=Mock(),
|
|
|
|
|
+ review_fetcher=lambda _account, _ids: [review],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(tencent.deleted_creatives, [])
|
|
|
|
|
+ self.assertEqual(tencent.deleted_components, [])
|
|
|
|
|
+ self.assertEqual(summary["deleted"], 0)
|
|
|
|
|
+ self.assertTrue(
|
|
|
|
|
+ any(
|
|
|
|
|
+ values.get("cleanup_status") == "SKIPPED_REVIEW_NOT_RECONFIRMED"
|
|
|
|
|
+ for _, values in updates
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def test_historical_component_candidate_without_id_is_never_executed(self):
|
|
|
|
|
+ from tools import creative_rejection_cleanup as cleanup
|
|
|
|
|
+
|
|
|
|
|
+ @contextmanager
|
|
|
|
|
+ def acquired_lock(_name):
|
|
|
|
|
+ yield True
|
|
|
|
|
+
|
|
|
|
|
+ tencent = Mock()
|
|
|
|
|
+ tencent.get_dynamic_creatives.return_value = [{
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "adgroup_id": 2,
|
|
|
|
|
+ "configured_status": "AD_STATUS_NORMAL",
|
|
|
|
|
+ }]
|
|
|
|
|
+ tencent.get_ads.return_value = [{"adgroup_id": 2}]
|
|
|
|
|
+ tencent.get_dynamic_creative.return_value = {
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "adgroup_id": 2,
|
|
|
|
|
+ "configured_status": "AD_STATUS_NORMAL",
|
|
|
|
|
+ }
|
|
|
|
|
+ review = {
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "element_result_list": [
|
|
|
|
|
+ {"element_id": 101, "review_status": "AD_STATUS_DENIED"}
|
|
|
|
|
+ ],
|
|
|
|
|
+ }
|
|
|
|
|
+ retry_item = {
|
|
|
|
|
+ "id": 7,
|
|
|
|
|
+ "account_id": 1,
|
|
|
|
|
+ "agency_name": "",
|
|
|
|
|
+ "adgroup_id": 2,
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "cleanup_action": "DELETE_COMPONENTS",
|
|
|
|
|
+ "target_component_ids_json": "[]",
|
|
|
|
|
+ "target_element_ids_json": "[101]",
|
|
|
|
|
+ "cleanup_status": "DISCOVERED",
|
|
|
|
|
+ }
|
|
|
|
|
+ updates = []
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as directory, patch.dict(
|
|
|
|
|
+ os.environ,
|
|
|
|
|
+ {
|
|
|
|
|
+ "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOK_ENABLED": "1",
|
|
|
|
|
+ "FEISHU_OPERATOR_CHAT_ID": "chat-operator",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOKS_JSON": "{}",
|
|
|
|
|
+ },
|
|
|
|
|
+ clear=False,
|
|
|
|
|
+ ), patch.object(cleanup, "initialize_schema"), patch.object(
|
|
|
|
|
+ cleanup, "resolve_end_date", return_value="20260810"
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "fetch_daily_data", return_value=pd.DataFrame()
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "fetch_account_agency_fallbacks", return_value={}
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "prefetch_account_access_tokens", return_value={1: "token-account-1"}
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "upsert_cleanup_candidate"
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup,
|
|
|
|
|
+ "update_cleanup_item",
|
|
|
|
|
+ side_effect=lambda item_id, **values: updates.append((item_id, values)),
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "load_unnotified_deleted_items", return_value=[]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "advisory_lock", side_effect=acquired_lock
|
|
|
|
|
+ ):
|
|
|
|
|
+ cleanup.run_rejected_creative_cleanup(
|
|
|
|
|
+ output_dir=Path(directory),
|
|
|
|
|
+ tencent=tencent,
|
|
|
|
|
+ odps=Mock(),
|
|
|
|
|
+ review_fetcher=lambda _account, _ids: [review],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ tencent.delete_dynamic_creative.assert_not_called()
|
|
|
|
|
+ tencent.delete_creative_component.assert_not_called()
|
|
|
|
|
+ self.assertTrue(
|
|
|
|
|
+ any(
|
|
|
|
|
+ values.get("cleanup_status") == "SKIPPED_REVIEW_NOT_RECONFIRMED"
|
|
|
|
|
+ for _, values in updates
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def test_dry_run_discovers_but_never_deletes(self):
|
|
|
|
|
+ from tools import creative_rejection_cleanup as cleanup
|
|
|
|
|
+
|
|
|
|
|
+ tencent = Mock()
|
|
|
|
|
+ tencent.get_dynamic_creatives.return_value = [
|
|
|
|
|
+ {
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "adgroup_id": 2,
|
|
|
|
|
+ "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+ tencent.get_ads.return_value = [{"adgroup_id": 2}]
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as directory, patch.dict(
|
|
|
|
|
+ os.environ,
|
|
|
|
|
+ {
|
|
|
|
|
+ "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOK_ENABLED": "0",
|
|
|
|
|
+ },
|
|
|
|
|
+ clear=False,
|
|
|
|
|
+ ), patch.object(cleanup, "initialize_schema"), patch.object(
|
|
|
|
|
+ cleanup, "resolve_end_date", return_value="20260810"
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "fetch_daily_data", return_value=pd.DataFrame()
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "fetch_account_agency_fallbacks", return_value={}
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup,
|
|
|
|
|
+ "prefetch_account_access_tokens",
|
|
|
|
|
+ return_value={1: "access-token-account-1"},
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "upsert_cleanup_candidate"
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "load_retryable_cleanup_items", return_value=[{"id": 7}]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "load_unnotified_deleted_items", return_value=[]
|
|
|
|
|
+ ):
|
|
|
|
|
+ summary = cleanup.run_rejected_creative_cleanup(
|
|
|
|
|
+ output_dir=Path(directory),
|
|
|
|
|
+ tencent=tencent,
|
|
|
|
|
+ odps=Mock(),
|
|
|
|
|
+ review_fetcher=lambda _account, _ids: [],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ tencent.delete_dynamic_creative.assert_not_called()
|
|
|
|
|
+ self.assertFalse(summary["apply_enabled"])
|
|
|
|
|
+ self.assertEqual(summary["pending_cleanup"], 1)
|
|
|
|
|
+
|
|
|
|
|
+ def test_disabled_webhook_keeps_manual_alert_pending_without_feishu_client(self):
|
|
|
|
|
+ from tools import creative_rejection_cleanup as cleanup
|
|
|
|
|
+
|
|
|
|
|
+ tencent = Mock()
|
|
|
|
|
+ tencent.get_dynamic_creatives.return_value = []
|
|
|
|
|
+ tencent.get_ads.return_value = []
|
|
|
|
|
+ pending_alert = {
|
|
|
|
|
+ "id": 7,
|
|
|
|
|
+ "agency_name": "棱镜",
|
|
|
|
|
+ "cleanup_status": "ALERT_PENDING",
|
|
|
|
|
+ }
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as directory, patch.dict(
|
|
|
|
|
+ os.environ,
|
|
|
|
|
+ {
|
|
|
|
|
+ "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOK_ENABLED": "0",
|
|
|
|
|
+ "FEISHU_APP_ID": "",
|
|
|
|
|
+ "FEISHU_APP_SECRET": "",
|
|
|
|
|
+ },
|
|
|
|
|
+ clear=False,
|
|
|
|
|
+ ), patch.object(cleanup, "initialize_schema"), patch.object(
|
|
|
|
|
+ cleanup, "resolve_end_date", return_value="20260810"
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "fetch_daily_data", return_value=pd.DataFrame()
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "fetch_recent_spend_accounts", return_value=[]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "prefetch_account_access_tokens", return_value={}
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "load_retryable_cleanup_items", return_value=[]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "load_unnotified_deleted_items", return_value=[pending_alert]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup, "RoiFeishuPublisher"
|
|
|
|
|
+ ) as publisher, patch.object(
|
|
|
|
|
+ cleanup, "publish_agency_reports"
|
|
|
|
|
+ ) as publish:
|
|
|
|
|
+ summary = cleanup.run_rejected_creative_cleanup(
|
|
|
|
|
+ output_dir=Path(directory),
|
|
|
|
|
+ tencent=tencent,
|
|
|
|
|
+ odps=Mock(),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ publisher.assert_not_called()
|
|
|
|
|
+ publish.assert_not_called()
|
|
|
|
|
+ self.assertEqual(summary["deliveries"], [])
|
|
|
|
|
+
|
|
|
|
|
+ def test_apply_requires_operator_chat_before_any_external_work(self):
|
|
|
|
|
+ from tools import creative_rejection_cleanup as cleanup
|
|
|
|
|
+
|
|
|
|
|
+ with patch.dict(
|
|
|
|
|
+ os.environ,
|
|
|
|
|
+ {
|
|
|
|
|
+ "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOK_ENABLED": "1",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOKS_JSON": json.dumps(
|
|
|
|
|
+ {
|
|
|
|
|
+ "棱镜": (
|
|
|
|
|
+ "https://open.feishu.cn/open-apis/bot/v2/hook/"
|
|
|
|
|
+ "test-cleanup-route"
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ ),
|
|
|
|
|
+ "FEISHU_OPERATOR_CHAT_ID": "",
|
|
|
|
|
+ },
|
|
|
|
|
+ clear=False,
|
|
|
|
|
+ ), patch.object(cleanup, "initialize_schema") as initialize:
|
|
|
|
|
+ with self.assertRaisesRegex(RuntimeError, "FEISHU_OPERATOR_CHAT_ID"):
|
|
|
|
|
+ cleanup.run_rejected_creative_cleanup(output_dir=Path("unused"))
|
|
|
|
|
+
|
|
|
|
|
+ initialize.assert_not_called()
|
|
|
|
|
+
|
|
|
|
|
+ def test_missing_agency_defers_before_tencent_delete(self):
|
|
|
|
|
+ from tools import creative_rejection_cleanup as cleanup
|
|
|
|
|
+
|
|
|
|
|
+ tencent = Mock()
|
|
|
|
|
+ tencent.get_dynamic_creatives.return_value = [{
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "adgroup_id": 2,
|
|
|
|
|
+ "creative_set_approval_status": cleanup.CREATIVE_DENIED_STATUS,
|
|
|
|
|
+ }]
|
|
|
|
|
+ tencent.get_ads.return_value = [{"adgroup_id": 2}]
|
|
|
|
|
+ retry_item = {
|
|
|
|
|
+ "id": 7,
|
|
|
|
|
+ "account_id": 1,
|
|
|
|
|
+ "agency_name": "",
|
|
|
|
|
+ "adgroup_id": 2,
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "cleanup_action": cleanup.DELETE_CREATIVE,
|
|
|
|
|
+ "cleanup_status": "DISCOVERED",
|
|
|
|
|
+ }
|
|
|
|
|
+ updates = []
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as directory, patch.dict(
|
|
|
|
|
+ os.environ,
|
|
|
|
|
+ {
|
|
|
|
|
+ "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOK_ENABLED": "1",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOKS_JSON": json.dumps({
|
|
|
|
|
+ "代理A": (
|
|
|
|
|
+ "https://open.feishu.cn/open-apis/bot/v2/hook/"
|
|
|
|
|
+ "test-cleanup-route"
|
|
|
|
|
+ )
|
|
|
|
|
+ }),
|
|
|
|
|
+ "FEISHU_OPERATOR_CHAT_ID": "chat-operator",
|
|
|
|
|
+ },
|
|
|
|
|
+ clear=False,
|
|
|
|
|
+ ), patch.object(cleanup, "initialize_schema"), patch.object(
|
|
|
|
|
+ cleanup, "resolve_end_date", return_value="20260812"
|
|
|
|
|
+ ), patch.object(cleanup, "fetch_daily_data", return_value=pd.DataFrame()), patch.object(
|
|
|
|
|
+ cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
|
|
|
|
|
+ ), patch.object(cleanup, "fetch_account_agency_fallbacks", return_value={}), patch.object(
|
|
|
|
|
+ cleanup, "prefetch_account_access_tokens", return_value={}
|
|
|
|
|
+ ), patch.object(cleanup, "upsert_cleanup_candidate"), patch.object(
|
|
|
|
|
+ cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup,
|
|
|
|
|
+ "update_cleanup_item",
|
|
|
|
|
+ side_effect=lambda item_id, **values: updates.append((item_id, values)),
|
|
|
|
|
+ ), patch.object(cleanup, "load_unnotified_deleted_items", return_value=[]), patch.object(
|
|
|
|
|
+ cleanup, "advisory_lock"
|
|
|
|
|
+ ) as lock:
|
|
|
|
|
+ summary = cleanup.run_rejected_creative_cleanup(
|
|
|
|
|
+ output_dir=Path(directory),
|
|
|
|
|
+ now=datetime(2026, 8, 13, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai")),
|
|
|
|
|
+ tencent=tencent,
|
|
|
|
|
+ odps=Mock(),
|
|
|
|
|
+ review_fetcher=lambda _account, _ids: [],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ tencent.delete_dynamic_creative.assert_not_called()
|
|
|
|
|
+ lock.assert_not_called()
|
|
|
|
|
+ self.assertEqual(summary["deferred"], 1)
|
|
|
|
|
+ self.assertTrue(any(
|
|
|
|
|
+ values.get("cleanup_status") == "DEFERRED"
|
|
|
|
|
+ and "代理商归属为空" in values.get("error_message", "")
|
|
|
|
|
+ for _, values in updates
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+ def test_unknown_write_result_recovers_from_missing_creative_without_redelete(self):
|
|
|
|
|
+ from tools import creative_rejection_cleanup as cleanup
|
|
|
|
|
+
|
|
|
|
|
+ @contextmanager
|
|
|
|
|
+ def acquired_lock(_name):
|
|
|
|
|
+ yield True
|
|
|
|
|
+
|
|
|
|
|
+ tencent = Mock()
|
|
|
|
|
+ tencent.get_dynamic_creatives.return_value = []
|
|
|
|
|
+ tencent.get_ads.return_value = []
|
|
|
|
|
+ tencent.get_dynamic_creative.side_effect = RuntimeError(
|
|
|
|
|
+ "Dynamic creative not found: account=1 creative=3"
|
|
|
|
|
+ )
|
|
|
|
|
+ retry_item = {
|
|
|
|
|
+ "id": 7,
|
|
|
|
|
+ "account_id": 1,
|
|
|
|
|
+ "agency_name": "代理A",
|
|
|
|
|
+ "adgroup_id": 2,
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "cleanup_action": cleanup.DELETE_CREATIVE,
|
|
|
|
|
+ "cleanup_status": "WRITE_OUTCOME_UNKNOWN",
|
|
|
|
|
+ }
|
|
|
|
|
+ updates = []
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as directory, patch.dict(
|
|
|
|
|
+ os.environ,
|
|
|
|
|
+ {
|
|
|
|
|
+ "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOK_ENABLED": "1",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOKS_JSON": json.dumps({
|
|
|
|
|
+ "代理A": (
|
|
|
|
|
+ "https://open.feishu.cn/open-apis/bot/v2/hook/"
|
|
|
|
|
+ "test-cleanup-route"
|
|
|
|
|
+ )
|
|
|
|
|
+ }),
|
|
|
|
|
+ "FEISHU_OPERATOR_CHAT_ID": "chat-operator",
|
|
|
|
|
+ },
|
|
|
|
|
+ clear=False,
|
|
|
|
|
+ ), patch.object(cleanup, "initialize_schema"), patch.object(
|
|
|
|
|
+ cleanup, "resolve_end_date", return_value="20260812"
|
|
|
|
|
+ ), patch.object(cleanup, "fetch_daily_data", return_value=pd.DataFrame()), patch.object(
|
|
|
|
|
+ cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
|
|
|
|
|
+ ), patch.object(cleanup, "fetch_account_agency_fallbacks", return_value={}), patch.object(
|
|
|
|
|
+ cleanup, "prefetch_account_access_tokens", return_value={}
|
|
|
|
|
+ ), patch.object(cleanup, "upsert_cleanup_candidate"), patch.object(
|
|
|
|
|
+ cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
|
|
|
|
|
+ ), patch.object(
|
|
|
|
|
+ cleanup,
|
|
|
|
|
+ "update_cleanup_item",
|
|
|
|
|
+ side_effect=lambda item_id, **values: updates.append((item_id, values)),
|
|
|
|
|
+ ), patch.object(cleanup, "load_unnotified_deleted_items", return_value=[]), patch.object(
|
|
|
|
|
+ cleanup, "advisory_lock", side_effect=acquired_lock
|
|
|
|
|
+ ):
|
|
|
|
|
+ summary = cleanup.run_rejected_creative_cleanup(
|
|
|
|
|
+ output_dir=Path(directory),
|
|
|
|
|
+ now=datetime(2026, 8, 13, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai")),
|
|
|
|
|
+ tencent=tencent,
|
|
|
|
|
+ odps=Mock(),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ tencent.delete_dynamic_creative.assert_not_called()
|
|
|
|
|
+ self.assertEqual(summary["deleted"], 1)
|
|
|
|
|
+ self.assertTrue(any(
|
|
|
|
|
+ values.get("cleanup_status") == "CREATIVE_DELETED"
|
|
|
|
|
+ for _, values in updates
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+ def test_notification_channels_retry_independently(self):
|
|
|
|
|
+ from tools import creative_rejection_cleanup as cleanup
|
|
|
|
|
+
|
|
|
|
|
+ base_row = {
|
|
|
|
|
+ "id": 7,
|
|
|
|
|
+ "check_date": date(2026, 8, 13),
|
|
|
|
|
+ "agency_name": "代理A",
|
|
|
|
|
+ "account_id": 1,
|
|
|
|
|
+ "adgroup_id": 2,
|
|
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
|
|
+ "cleanup_action": cleanup.ALERT_ONLY,
|
|
|
|
|
+ "cleanup_status": "ALERT_PENDING",
|
|
|
|
|
+ "reject_reason": "需人工判断",
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ def run_with(row):
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as directory, patch.dict(
|
|
|
|
|
+ os.environ,
|
|
|
|
|
+ {
|
|
|
|
|
+ "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOK_ENABLED": "1",
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOKS_JSON": json.dumps({
|
|
|
|
|
+ "代理A": (
|
|
|
|
|
+ "https://open.feishu.cn/open-apis/bot/v2/hook/"
|
|
|
|
|
+ "test-cleanup-route"
|
|
|
|
|
+ )
|
|
|
|
|
+ }),
|
|
|
|
|
+ "FEISHU_OPERATOR_CHAT_ID": "chat-operator",
|
|
|
|
|
+ },
|
|
|
|
|
+ clear=False,
|
|
|
|
|
+ ), patch.object(cleanup, "initialize_schema"), patch.object(
|
|
|
|
|
+ cleanup, "resolve_end_date", return_value="20260812"
|
|
|
|
|
+ ), patch.object(cleanup, "fetch_daily_data", return_value=pd.DataFrame()), patch.object(
|
|
|
|
|
+ cleanup, "fetch_recent_spend_accounts", return_value=[]
|
|
|
|
|
+ ), patch.object(cleanup, "prefetch_account_access_tokens", return_value={}), patch.object(
|
|
|
|
|
+ cleanup, "load_retryable_cleanup_items", return_value=[]
|
|
|
|
|
+ ), patch.object(cleanup, "load_unnotified_deleted_items", return_value=[row]), patch.object(
|
|
|
|
|
+ cleanup, "publish_agency_reports", return_value=[{
|
|
|
|
|
+ "agency_name": "代理A", "status": "SENT"
|
|
|
|
|
+ }]
|
|
|
|
|
+ ) as agency_publish, patch.object(
|
|
|
|
|
+ cleanup, "publish_cleanup_operator_summary", return_value={
|
|
|
|
|
+ "route": cleanup.OPERATOR_SUMMARY_ROUTE, "status": "SENT"
|
|
|
|
|
+ }
|
|
|
|
|
+ ) as operator_publish, patch.object(
|
|
|
|
|
+ cleanup, "mark_cleanup_items_notified"
|
|
|
|
|
+ ) as mark_agency, patch.object(
|
|
|
|
|
+ cleanup, "mark_cleanup_items_operator_notified"
|
|
|
|
|
+ ) as mark_operator:
|
|
|
|
|
+ cleanup.run_rejected_creative_cleanup(
|
|
|
|
|
+ output_dir=Path(directory),
|
|
|
|
|
+ now=datetime(2026, 8, 13, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai")),
|
|
|
|
|
+ tencent=Mock(),
|
|
|
|
|
+ odps=Mock(),
|
|
|
|
|
+ publisher=Mock(),
|
|
|
|
|
+ )
|
|
|
|
|
+ return agency_publish, operator_publish, mark_agency, mark_operator
|
|
|
|
|
+
|
|
|
|
|
+ agency, operator, mark_agency, mark_operator = run_with({
|
|
|
|
|
+ **base_row,
|
|
|
|
|
+ "agency_notified_at": datetime(2026, 8, 13, 11, 0),
|
|
|
|
|
+ "operator_notified_at": None,
|
|
|
|
|
+ })
|
|
|
|
|
+ agency.assert_not_called()
|
|
|
|
|
+ operator.assert_called_once()
|
|
|
|
|
+ mark_agency.assert_not_called()
|
|
|
|
|
+ mark_operator.assert_called_once()
|
|
|
|
|
+
|
|
|
|
|
+ agency, operator, mark_agency, mark_operator = run_with({
|
|
|
|
|
+ **base_row,
|
|
|
|
|
+ "agency_notified_at": None,
|
|
|
|
|
+ "operator_notified_at": datetime(2026, 8, 13, 11, 0),
|
|
|
|
|
+ })
|
|
|
|
|
+ agency.assert_called_once()
|
|
|
|
|
+ operator.assert_not_called()
|
|
|
|
|
+ mark_agency.assert_called_once()
|
|
|
|
|
+ mark_operator.assert_not_called()
|
|
|
|
|
+
|
|
|
|
|
+ def test_cleanup_entry_returns_nonzero_for_notification_failure(self):
|
|
|
|
|
+ from examples.auto_put_ad_mini import cleanup_rejected_creatives as entry
|
|
|
|
|
+
|
|
|
|
|
+ summary = {
|
|
|
|
|
+ "scan_errors": [],
|
|
|
|
|
+ "delete_errors": [],
|
|
|
|
|
+ "notification_errors": ["operator failed"],
|
|
|
|
|
+ }
|
|
|
|
|
+ with patch("logging_setup.setup_logging"), patch.object(
|
|
|
|
|
+ entry, "run_rejected_creative_cleanup", return_value=summary
|
|
|
|
|
+ ):
|
|
|
|
|
+ self.assertEqual(entry.main(), 1)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
if __name__ == "__main__":
|
|
if __name__ == "__main__":
|
|
|
unittest.main()
|
|
unittest.main()
|