ferrero-opentext/Python-Version/scripts/a4_box_uploader.py
nickviljoen ba4f1a9bf7 Feature: Global live campaigns CSV + B4 closure flow
Wires B-series (global) campaigns into OMG using the same Box
automation as A-series. Mirrors the A1/A4 lifecycle for B1/B4.

- b1_to_b2_download: after B2 status update, mark live=YES status=B2
  and upload live_campaigns_global_<ts>.csv to the existing Box folder
  (BOX_LIVE_CAMPAIGNS_FOLDER_ID, 352181382858 in PROD). Filename keeps
  the live_campaigns_ prefix so the existing OMG automation rule picks
  it up.
- b4_box_uploader (new): polls DAM for status B4, marks live=NO, regens
  the global CSV. Mirrors a4_box_uploader.
- a4_box_uploader: reads prior status before overwriting; if it was
  B-series, regenerate the global CSV instead. b4_box_uploader does the
  symmetric A-series fallback. Defensive in case DAM doesn't enforce
  type-specific status transitions.
- database: add get_all_live_global_campaigns() (status LIKE 'B%').
  Tighten get_all_live_campaigns() to status LIKE 'A%' so any cross-type
  rows can't leak into the wrong CSV.
- orchestrator + orchestrator-prod: register B4 Box Uploader at 10min.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:12:49 +02:00

361 lines
12 KiB
Python

#!/usr/bin/env python3
"""
A4 Box Uploader
Monitors campaigns with status A4 (Not Going Live)
Updates status in DB to live_campaign='NO'
Generates and uploads updated CSV of live campaigns to Box
"""
import sys
import os
import time
import logging
import argparse
import csv
from datetime import datetime, timezone
# Add shared library to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from shared.config_loader import load_config
from shared.dam_client import DAMClient
from shared.box_client import BoxClient
from shared.database import Database
from shared.notifier import Notifier
# Setup logging with rotation
from logging.handlers import RotatingFileHandler
# Create logs directory if it doesn't exist
os.makedirs('logs', exist_ok=True)
os.makedirs('logs/backup', exist_ok=True)
# Configure logging with rotation
log_handler = RotatingFileHandler(
'logs/a4_box.log',
maxBytes=10*1024*1024, # 10MB per file
backupCount=28
)
log_handler.setLevel(logging.INFO)
log_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
logging.basicConfig(
level=logging.INFO,
handlers=[log_handler, console_handler]
)
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.
"""
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)
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 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")
return False
upload_result = box.upload_file(
file_path=csv_path,
folder_id=folder_id,
target_filename=csv_filename
)
logger.info("Uploaded CSV to Box: {} (File ID: {})".format(
csv_filename, upload_result['file_id']
))
# Clean up
os.remove(csv_path)
return True
except Exception as e:
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
"""
campaign_id = campaign['asset_id']
campaign_name = campaign['campaign_name']
campaign_number = campaign.get('campaign_id') or 'UNKNOWN'
logger.info("=" * 60)
logger.info("Processing A4 campaign: {} ({})".format(campaign_name, campaign_number))
logger.info("=" * 60)
try:
# Check if campaign already processed
campaign_check = db.check_campaign_processed(campaign_id)
if campaign_check['exists'] and campaign_check['webhook_sent']:
# Note: We reuse 'webhook_sent' column to track if we've processed it,
# even though we aren't sending a webhook anymore.
logger.info("Campaign already processed")
logger.info(" Processed at: {}".format(campaign_check['webhook_sent_at']))
logger.info(" Status: {}".format(campaign_check['status']))
logger.info(" Live Campaign: {}".format(campaign_check['live_campaign']))
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'))
db.record_campaign_status(
campaign_id=campaign_id,
campaign_number=campaign_number,
campaign_name=campaign_name,
live_campaign='NO', # A4 campaigns are NOT going live
status='A4',
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)
if csv_success:
logger.info("✓ CSV report uploaded successfully")
else:
logger.error("✗ CSV report generation/upload failed")
# Send email notification (internal)
notifier.send_email(
template_name='a4_webhook_sent', # Reuse template or create new one? Reusing for now as it conveys "A4 processed"
recipients=config['notifications']['recipients']['success'],
data={
'campaign_name': campaign_name,
'campaign_id': campaign_id,
'campaign_number': campaign_number,
'webhook_url': 'CSV Uploaded to Box' # Placeholder
}
)
return {'success': True, 'processed': True}
except Exception as e:
logger.error("Campaign processing failed: {}".format(str(e)))
return {'success': False, 'processed': False}
def main():
"""Main polling loop"""
parser = argparse.ArgumentParser(description='Ferrero A4 Box Uploader')
parser.add_argument('--auth-pfx', action='store_true',
help='Use mTLS certificate authentication (Legacy APIM)')
parser.add_argument('--auth-pfx-v2', action='store_true',
help='Use mTLS V2 (Hybrid) authentication')
args = parser.parse_args()
logger.info("=" * 60)
logger.info("Ferrero A4 Box Uploader Starting")
# Determine auth mode
auth_mode = 'oauth'
if args.auth_pfx_v2:
auth_mode = 'mtls_v2'
logger.info("Authentication: mTLS V2 (Hybrid)")
elif args.auth_pfx:
auth_mode = 'mtls'
logger.info("Authentication: mTLS Certificate (Legacy)")
else:
logger.info("Authentication: OAuth2 (default)")
logger.info("=" * 60)
# Load configuration
config = load_config('config/config.yaml')
# Initialize clients
dam = DAMClient(config, auth_mode=auth_mode)
box = BoxClient(config) # Need Box client now
db = Database(config)
notifier = Notifier(config)
# Test connections
logger.info("Testing connections...")
if not dam.test_connection():
logger.error("DAM connection failed - exiting")
sys.exit(1)
if not box.test_connection():
logger.error("Box connection failed - exiting")
sys.exit(1)
if not db.test_connection():
logger.error("Database connection failed - exiting")
sys.exit(1)
logger.info("All connections OK")
logger.info("")
try:
logger.info("Searching for A4 campaigns...")
campaigns = dam.search_campaigns(status='A4')
if not campaigns:
logger.info("No A4 campaigns found - exiting")
db.close()
sys.exit(0)
logger.info("Found {} A4 campaign(s) - processing all".format(len(campaigns)))
logger.info("")
processed_count = 0
failed_count = 0
already_processed_count = 0
for campaign in campaigns:
result = process_campaign(campaign, dam, box, db, notifier, config)
if result['success']:
if result.get('processed'):
processed_count += 1
if result.get('already_processed'):
already_processed_count += 1
else:
failed_count += 1
logger.info("")
logger.info("=" * 60)
logger.info("A4 Box Uploader Summary")
logger.info("=" * 60)
logger.info("Total campaigns found: {}".format(len(campaigns)))
logger.info("Processed (CSV updated): {}".format(processed_count))
logger.info("Already processed: {}".format(already_processed_count))
logger.info("Failed: {}".format(failed_count))
logger.info("=" * 60)
db.close()
if failed_count == 0:
sys.exit(0)
elif processed_count > 0:
sys.exit(0)
else:
sys.exit(1)
except Exception as e:
logger.critical("Script error: {}".format(str(e)))
notifier.send_email(
template_name='upload_failed',
recipients=config['notifications']['recipients']['critical'],
data={
'filename': 'A4 Box Uploader',
'tracking_id': 'N/A',
'error': str(e)
}
)
db.close()
sys.exit(1)
if __name__ == '__main__':
main()