Fix: Merge A+B live campaigns into single CSV for OMG

OMG's Box automation treats each new live_campaigns_*.csv as a full-list
replacement, so the per-series global CSV introduced 2026-04-30 stomped
the local list whenever a B1→B2 ran. Collapse to one combined CSV
(A-series + B-series) emitted by every handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nickviljoen 2026-05-04 17:36:43 +02:00
parent 28586308d7
commit 6d6213024a
4 changed files with 33 additions and 208 deletions

View file

@ -52,23 +52,20 @@ logger = logging.getLogger('A4Box')
def generate_and_upload_csv(db, box, config):
"""
Generate CSV of all live LOCAL campaigns and upload to Box.
Filename prefix `live_campaigns_` is what OMG's Box automation watches for.
Generate the combined live-campaigns CSV (A-series + B-series) and upload
to Box. OMG's automation treats each new file as a full replacement of
its live list, so we always emit the complete list under one filename.
"""
try:
logger.info("Generating live campaigns CSV...")
# 1. Get all live campaigns from DB
campaigns = db.get_all_live_campaigns()
if not campaigns:
logger.warning("No live campaigns found to report")
# Even if empty, we might want to upload an empty CSV to clear the list?
# For now, let's upload it even if empty to reflect that no campaigns are live.
logger.info("Found {} live campaigns".format(len(campaigns)))
# 2. Generate CSV file
timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d_%H%M%S_UTC')
csv_filename = 'live_campaigns_{}.csv'.format(timestamp)
csv_path = os.path.join('temp', csv_filename)
@ -88,7 +85,6 @@ def generate_and_upload_csv(db, box, config):
logger.info("Generated CSV: {}".format(csv_path))
# 3. Upload to Box
folder_id = config['box'].get('live_campaigns_folder_id')
if not folder_id:
logger.error("Box live_campaigns_folder_id not configured")
@ -104,7 +100,6 @@ def generate_and_upload_csv(db, box, config):
csv_filename, upload_result['file_id']
))
# Clean up
os.remove(csv_path)
return True
@ -112,63 +107,6 @@ def generate_and_upload_csv(db, box, config):
logger.error("Failed to generate/upload CSV: {}".format(str(e)))
return False
def generate_and_upload_global_csv(db, box, config):
"""
Generate CSV of all live GLOBAL campaigns (B-series) and upload to Box.
Goes to the same Box folder as the local CSV; filename keeps the
`live_campaigns_` prefix so OMG's Box automation picks it up too.
"""
try:
logger.info("Generating live GLOBAL campaigns CSV...")
campaigns = db.get_all_live_global_campaigns()
if not campaigns:
logger.warning("No live global campaigns found to report")
logger.info("Found {} live global campaigns".format(len(campaigns)))
timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d_%H%M%S_UTC')
csv_filename = 'live_campaigns_global_{}.csv'.format(timestamp)
csv_path = os.path.join('temp', csv_filename)
os.makedirs('temp', exist_ok=True)
with open(csv_path, 'w', newline='') as csvfile:
fieldnames = ['code', 'description']
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for camp in campaigns:
writer.writerow({
'code': "{}-{}".format(camp['campaign_number'], camp['campaign_name']),
'description': camp['campaign_name']
})
logger.info("Generated global CSV: {}".format(csv_path))
folder_id = config['box'].get('live_campaigns_folder_id')
if not folder_id:
logger.error("Box live_campaigns_folder_id not configured")
return False
upload_result = box.upload_file(
file_path=csv_path,
folder_id=folder_id,
target_filename=csv_filename
)
logger.info("Uploaded global CSV to Box: {} (File ID: {})".format(
csv_filename, upload_result['file_id']
))
os.remove(csv_path)
return True
except Exception as e:
logger.error("Failed to generate/upload global CSV: {}".format(str(e)))
return False
def process_campaign(campaign, dam, box, db, notifier, config):
"""
Process A4 campaign - update status and upload CSV
@ -195,15 +133,9 @@ def process_campaign(campaign, dam, box, db, notifier, config):
logger.info("Skipping to avoid duplicate processing")
return {'success': True, 'processed': False, 'already_processed': True}
# Look up the campaign's PRIOR status before we overwrite it.
# If it was B-series (B2), this is a global campaign that a manager
# closed via A4 — route the CSV regen to the global CSV instead.
prior_status = (campaign_check.get('status') or '') if campaign_check.get('exists') else ''
is_global = prior_status.startswith('B')
# Record campaign status in database
# This marks it as NOT LIVE
logger.info("Recording campaign status in database (Live: NO, prior status: {})...".format(prior_status or 'unknown'))
logger.info("Recording campaign status in database (Live: NO)...")
db.record_campaign_status(
campaign_id=campaign_id,
campaign_number=campaign_number,
@ -213,14 +145,8 @@ def process_campaign(campaign, dam, box, db, notifier, config):
webhook_sent=True # Mark as processed
)
# Regenerate the CSV that this campaign was on. Global if prior was
# B-series, local otherwise.
if is_global:
logger.info("Prior status was {} - regenerating GLOBAL live campaigns CSV...".format(prior_status))
csv_success = generate_and_upload_global_csv(db, box, config)
else:
logger.info("Generating and uploading updated live campaigns CSV...")
csv_success = generate_and_upload_csv(db, box, config)
logger.info("Generating and uploading updated live campaigns CSV...")
csv_success = generate_and_upload_csv(db, box, config)
if csv_success:
logger.info("✓ CSV report uploaded successfully")

View file

@ -110,25 +110,24 @@ def extract_creativex_from_dam_metadata(asset_metadata):
return {'score': None, 'url': None}
def generate_and_upload_global_csv(db, box, config):
def generate_and_upload_csv(db, box, config):
"""
Generate CSV of all live GLOBAL campaigns (B-series) and upload to Box.
Same Box folder as the local CSV; filename keeps the `live_campaigns_`
prefix so OMG's Box automation picks it up.
Mirrors the helper of the same name in a4_box_uploader.py.
Generate the combined live-campaigns CSV (A-series + B-series) and upload
to Box. OMG's automation treats each new file as a full replacement of
its live list, so we always emit the complete list under one filename.
"""
try:
logger.info("Generating live GLOBAL campaigns CSV...")
logger.info("Generating live campaigns CSV...")
campaigns = db.get_all_live_global_campaigns()
campaigns = db.get_all_live_campaigns()
if not campaigns:
logger.warning("No live global campaigns found to report")
logger.warning("No live campaigns found to report")
logger.info("Found {} live global campaigns".format(len(campaigns)))
logger.info("Found {} live campaigns".format(len(campaigns)))
timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d_%H%M%S_UTC')
csv_filename = 'live_campaigns_global_{}.csv'.format(timestamp)
csv_filename = 'live_campaigns_{}.csv'.format(timestamp)
csv_path = os.path.join('temp', csv_filename)
os.makedirs('temp', exist_ok=True)
@ -144,7 +143,7 @@ def generate_and_upload_global_csv(db, box, config):
'description': camp['campaign_name']
})
logger.info("Generated global CSV: {}".format(csv_path))
logger.info("Generated CSV: {}".format(csv_path))
folder_id = config['box'].get('live_campaigns_folder_id')
if not folder_id:
@ -157,7 +156,7 @@ def generate_and_upload_global_csv(db, box, config):
target_filename=csv_filename
)
logger.info("Uploaded global CSV to Box: {} (File ID: {})".format(
logger.info("Uploaded CSV to Box: {} (File ID: {})".format(
csv_filename, upload_result['file_id']
))
@ -165,7 +164,7 @@ def generate_and_upload_global_csv(db, box, config):
return True
except Exception as e:
logger.error("Failed to generate/upload global CSV: {}".format(str(e)))
logger.error("Failed to generate/upload CSV: {}".format(str(e)))
return False
@ -404,14 +403,14 @@ def process_campaign(campaign, dam, box, db, notifier, config):
webhook_sent=False # B-series workflow doesn't send a webhook
)
# Regenerate and upload the global live campaigns CSV to Box.
# Box automation forwards it to OMG.
logger.info("Generating and uploading global live campaigns CSV...")
csv_success = generate_and_upload_global_csv(db, box, config)
# Regenerate and upload the combined live campaigns CSV to Box.
# Box automation forwards it to OMG as a full-list replacement.
logger.info("Generating and uploading live campaigns CSV...")
csv_success = generate_and_upload_csv(db, box, config)
if csv_success:
logger.info(" Global CSV report uploaded successfully")
logger.info(" CSV report uploaded successfully")
else:
logger.error(" Global CSV report generation/upload failed")
logger.error(" CSV report generation/upload failed")
# NOTE: B1→B2 workflow does NOT send webhook (only email notification)
# Webhook is only used for A1→A2 workflow

View file

@ -55,9 +55,9 @@ logger = logging.getLogger('B4Box')
def generate_and_upload_csv(db, box, config):
"""
Generate CSV of all live LOCAL campaigns and upload to Box.
Used here only as a fallback when a campaign closed via B4 was
actually a local one (defensive DAM likely prevents this).
Generate the combined live-campaigns CSV (A-series + B-series) and upload
to Box. OMG's automation treats each new file as a full replacement of
its live list, so we always emit the complete list under one filename.
"""
try:
logger.info("Generating live campaigns CSV...")
@ -111,64 +111,6 @@ def generate_and_upload_csv(db, box, config):
return False
def generate_and_upload_global_csv(db, box, config):
"""
Generate CSV of all live GLOBAL campaigns (B-series) and upload to Box.
Same Box folder as the local CSV; filename keeps the `live_campaigns_`
prefix so OMG's Box automation picks it up.
"""
try:
logger.info("Generating live GLOBAL campaigns CSV...")
campaigns = db.get_all_live_global_campaigns()
if not campaigns:
logger.warning("No live global campaigns found to report")
logger.info("Found {} live global campaigns".format(len(campaigns)))
timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d_%H%M%S_UTC')
csv_filename = 'live_campaigns_global_{}.csv'.format(timestamp)
csv_path = os.path.join('temp', csv_filename)
os.makedirs('temp', exist_ok=True)
with open(csv_path, 'w', newline='') as csvfile:
fieldnames = ['code', 'description']
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for camp in campaigns:
writer.writerow({
'code': "{}-{}".format(camp['campaign_number'], camp['campaign_name']),
'description': camp['campaign_name']
})
logger.info("Generated global CSV: {}".format(csv_path))
folder_id = config['box'].get('live_campaigns_folder_id')
if not folder_id:
logger.error("Box live_campaigns_folder_id not configured")
return False
upload_result = box.upload_file(
file_path=csv_path,
folder_id=folder_id,
target_filename=csv_filename
)
logger.info("Uploaded global CSV to Box: {} (File ID: {})".format(
csv_filename, upload_result['file_id']
))
os.remove(csv_path)
return True
except Exception as e:
logger.error("Failed to generate/upload global CSV: {}".format(str(e)))
return False
def process_campaign(campaign, dam, box, db, notifier, config):
"""
Process B4 campaign - mark not-live and regenerate the global CSV.
@ -192,13 +134,7 @@ def process_campaign(campaign, dam, box, db, notifier, config):
logger.info("Skipping to avoid duplicate processing")
return {'success': True, 'processed': False, 'already_processed': True}
# Symmetric prior-status routing: if a campaign reaches B4 but its prior
# status was A-series, regenerate the LOCAL CSV instead. Defensive —
# DAM workflows likely prevent this cross-type transition.
prior_status = (campaign_check.get('status') or '') if campaign_check.get('exists') else ''
is_local = prior_status.startswith('A')
logger.info("Recording campaign status in database (Live: NO, prior status: {})...".format(prior_status or 'unknown'))
logger.info("Recording campaign status in database (Live: NO)...")
db.record_campaign_status(
campaign_id=campaign_id,
campaign_number=campaign_number,
@ -208,12 +144,8 @@ def process_campaign(campaign, dam, box, db, notifier, config):
webhook_sent=True
)
if is_local:
logger.info("Prior status was {} - regenerating LOCAL live campaigns CSV...".format(prior_status))
csv_success = generate_and_upload_csv(db, box, config)
else:
logger.info("Generating and uploading updated GLOBAL live campaigns CSV...")
csv_success = generate_and_upload_global_csv(db, box, config)
logger.info("Generating and uploading updated live campaigns CSV...")
csv_success = generate_and_upload_csv(db, box, config)
if csv_success:
logger.info("✓ CSV report uploaded successfully")

View file

@ -1074,9 +1074,8 @@ class Database:
def get_all_live_campaigns(self):
"""
Get all live LOCAL campaigns (A-series) for the local CSV report.
Filters by status starting with 'A' to keep this CSV scoped to
local campaigns even if a B-series row is ever marked live.
Get all live campaigns (A-series local + B-series global) for the
single combined CSV that OMG ingests as a full replacement list.
"""
conn = self.get_connection()
try:
@ -1086,38 +1085,7 @@ class Database:
SELECT campaign_number, campaign_name
FROM campaign_status
WHERE live_campaign = 'YES'
AND status LIKE 'A%'
ORDER BY campaign_number DESC
""")
rows = cursor.fetchall()
campaigns = []
for row in rows:
campaigns.append({
'campaign_number': row[0],
'campaign_name': row[1]
})
return campaigns
finally:
cursor.close()
self.put_connection(conn)
def get_all_live_global_campaigns(self):
"""
Get all live GLOBAL campaigns (B-series) for the global CSV report.
"""
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
SELECT campaign_number, campaign_name
FROM campaign_status
WHERE live_campaign = 'YES'
AND status LIKE 'B%'
AND (status LIKE 'A%' OR status LIKE 'B%')
ORDER BY campaign_number DESC
""")