ferrero-opentext/Python-Version/scripts/a5_to_a6_download.py
DJP 631dba4390 Fix campaign ID storage - always set local_campaign_id
Critical Fix:
- extract_global_campaign_reference() now accepts campaign_id parameter
- Always sets local_campaign_id to current campaign as fallback
- Prevents NULL local_campaign_id when no Global Campaign Reference exists

Root Cause:
- Assets without Global Campaign Reference had NULL local_campaign_id
- Caused derivatives to be linked to wrong campaigns
- Same asset in multiple campaigns would share tracking IDs incorrectly

Impact:
- Every asset now has proper local_campaign_id
- Derivatives correctly linked to their source campaign
- Fixes issue where C000001177 assets were showing as C000002098

Changes:
- database.py: Added campaign_id parameter with fallback logic
- a1_to_a2_box_uploader.py: Pass campaign_number to function
- a5_to_a6_download.py: Pass campaign_number to function
2025-12-22 11:37:58 -05:00

422 lines
17 KiB
Python
Executable file

#!/usr/bin/env python3
"""
A5→A6 Rework Asset Downloader
Polls DAM for campaigns with status A5, downloads rework assets, uploads to Box
Updates status to A6 only when ALL assets successfully processed
Supports OAuth2 (default) and mTLS (--auth-pfx) authentication
Compatible with Python 3.6+
"""
import sys
import os
import time
import logging
import argparse
# Add shared library to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from shared.config_loader import load_config, load_field_mappings
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
# Keep 1 week of active logs (7 days * 10MB = 70MB)
# Backup rotates keep 4 weeks (28 backups * 10MB = 280MB total)
log_handler = RotatingFileHandler(
'logs/a5_to_a6.log',
maxBytes=10*1024*1024, # 10MB per file
backupCount=28 # Keep 28 rotated files (approximately 1 month)
)
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('A5toA6')
def process_campaign(campaign, dam, box, db, notifier, config):
"""
Process single campaign - download all rework assets
Returns:
dict with success, processed_count, failed_count
"""
campaign_id = campaign['asset_id']
campaign_name = campaign['campaign_name']
campaign_number = campaign.get('campaign_id', 'N/A')
logger.info("=" * 60)
logger.info("Processing campaign: {} ({})".format(campaign_name, campaign_number))
logger.info("=" * 60)
try:
# Get ALL assets from Final Assets folder (recursive search)
# A5 campaigns are Local Adaptation but use Final Assets folder (like B1)
# So we pass is_global=True to search Final Assets folder
all_assets = dam.get_master_assets(campaign_id, is_global=True)
logger.info("Found {} total assets in Final Assets folder".format(len(all_assets)))
# Filter for NOT APPROVED assets only
not_approved_assets = []
skipped_assets = []
for asset in all_assets:
if dam.is_asset_not_approved(asset):
not_approved_assets.append(asset)
else:
skipped_assets.append(asset)
logger.info("NOT APPROVED (rejected) assets: {}".format(len(not_approved_assets)))
logger.info("Approved/other status (skipped): {}".format(len(skipped_assets)))
# If NO rejected assets found, check if we've already notified about this campaign
if len(not_approved_assets) == 0:
logger.info("No NOT APPROVED assets found - all assets are approved")
# Check if we've already sent notification for this campaign
campaign_check = db.check_campaign_processed(campaign_id)
# Only skip if:
# 1. Campaign exists in database
# 2. It was marked as notified (webhook_sent = True)
# 3. Status is still A5 (hasn't moved forward and back)
if (campaign_check['exists'] and
campaign_check['webhook_sent'] and
campaign_check['status'] == 'A5'):
logger.info("Campaign already notified about no rejections (status still A5)")
logger.info(" Notified at: {}".format(campaign_check['webhook_sent_at']))
logger.info("Skipping duplicate notification")
return {'success': True, 'processed': 0, 'failed': 0}
# Record in database to prevent future duplicate emails
# Note: If campaign was A5 before, this updates the record
logger.info("Recording campaign status in database (first notification or status changed)...")
db.record_campaign_status(
campaign_id=campaign_id,
campaign_number=campaign_number,
campaign_name=campaign_name,
live_campaign='N/A', # Not applicable for A5
status='A5',
webhook_sent=True # Mark as notified
)
# Send "no rejections" email (only once per A5 status period)
notifier.send_email(
template_name='a5_to_a6_no_rejections',
recipients=config['notifications']['recipients']['success'],
data={
'campaign_name': campaign_name,
'campaign_id': campaign_id,
'campaign_number': campaign_number,
'total_assets': len(all_assets),
'skipped_count': len(skipped_assets)
}
)
logger.info("✓ Email sent: No rework required (will not send again unless status changes)")
return {'success': True, 'processed': 0, 'failed': 0}
# Track results
processed_assets = []
failed_assets = []
# Get Final Assets folder for upload directory
final_folder_id = dam.find_final_assets_folder(campaign_id)
if not final_folder_id:
logger.error("Final Assets folder not found")
return {'success': False, 'processed': 0, 'failed': len(not_approved_assets)}
# Process ONLY NOT APPROVED assets
for asset in not_approved_assets:
asset_id = asset['asset_id']
asset_name = asset.get('name', 'unknown')
folder_path = asset.get('folder_path', '') # Get subfolder path from recursive search
try:
if folder_path:
logger.info("Processing NOT APPROVED: {} (from subfolder: {})".format(asset_name, folder_path))
else:
logger.info("Processing NOT APPROVED: {}".format(asset_name))
# 1. Extract rejection details for email
rejection_details = dam.extract_rejection_details(asset)
# 2. Download from DAM
file_path = dam.download_asset(
asset_id,
output_dir='temp/downloads/{}'.format(campaign_id)
)
# 3. Extract Global Campaign Reference and Local Campaign ID from asset metadata
global_ref = db.extract_global_campaign_reference(asset, campaign_number)
# 4. Check if asset already exists in database (from A1→A2)
# This will either find existing tracking_id or generate new one
tracking_result = db.find_or_create_tracking_id(
opentext_id=asset_id,
local_campaign_id=global_ref['local_campaign_id']
)
tracking_id = tracking_result['tracking_id']
is_existing = tracking_result['is_existing']
if is_existing:
logger.info("Found existing tracking ID: {} (updating record)".format(tracking_id))
else:
logger.info("Generated new tracking ID: {}".format(tracking_id))
# 5. Upload to Box (Revisions folder with -Revisions suffix, preserve folder structure)
box_result = box.upload_with_tracking_id(
file_path=file_path,
campaign_id=campaign_number, # Use C000000078, not hex asset_id
campaign_name=campaign_name + "-Revisions", # Add -Revisions suffix
tracking_id=tracking_id,
subfolder_path=folder_path # Preserve DAM folder structure
)
# 5. Store in database with FULL metadata and campaign references
db_result = db.store_master_asset(
tracking_id=tracking_id,
opentext_id=asset_id,
asset_data=asset,
box_file_id=box_result['file_id'],
box_url=box_result['url'],
upload_folder_id=final_folder_id,
global_master_campaign_id=global_ref['global_master_campaign_id'],
global_master_folder_id=global_ref['global_master_folder_id'],
local_campaign_id=global_ref['local_campaign_id']
)
if db_result['success']:
processed_assets.append({
'asset_id': asset_id,
'asset_name': asset_name,
'tracking_id': tracking_id,
'box_file_id': box_result['file_id'],
'box_url': box_result['url'],
'is_existing': is_existing,
'folder_path': folder_path,
'rejection_details': rejection_details # Include rejection comments for email
})
logger.info("✓ Success: {}{}{}".format(
asset_name,
tracking_id,
" (updated)" if is_existing else " (new)"
))
else:
raise Exception("Database storage failed")
# Clean up temp file
os.remove(file_path)
except Exception as e:
logger.error("✗ Failed: {} - {}".format(asset_name, str(e)))
failed_assets.append({
'asset_id': asset_id,
'asset_name': asset_name,
'error': str(e)
})
# CHECK: All rejected assets processed successfully?
all_done = len(processed_assets) == len(not_approved_assets)
logger.info("")
logger.info("Campaign {} Results:".format(campaign_id))
logger.info(" NOT APPROVED (rejected): {}".format(len(not_approved_assets)))
logger.info(" Successfully processed: {}".format(len(processed_assets)))
logger.info(" Failed: {}".format(len(failed_assets)))
logger.info(" Approved/skipped: {}".format(len(skipped_assets)))
logger.info(" All Done: {}".format("YES" if all_done else "NO"))
logger.info("")
if all_done:
# ALL rejected assets processed - update status
logger.info("All NOT APPROVED assets processed - Updating status A5 → A6")
status_result = dam.update_campaign_status(campaign_id, 'A6')
if status_result['success']:
logger.info("✓ Status updated successfully")
# NOTE: No webhook for A5→A6 (rework workflow)
# Send success email with rejection details
notifier.send_email(
template_name='a5_to_a6_rejections',
recipients=config['notifications']['recipients']['success'],
data={
'campaign_name': campaign_name,
'campaign_id': campaign_id,
'campaign_number': campaign_number,
'rejected_count': len(processed_assets),
'skipped_count': len(skipped_assets),
'rejected_assets': processed_assets # Includes rejection_details
}
)
return {'success': True, 'processed': len(processed_assets), 'failed': 0}
else:
logger.error("✗ Status update failed: {}".format(status_result.get('error')))
# Don't send success notification if status update failed
return {'success': False, 'processed': len(processed_assets), 'failed': 0}
else:
# NOT all done - some failed
logger.warning("Campaign incomplete - NOT updating status (remains A5)")
# Send partial completion email with asset details
notifier.send_email(
template_name='a5_to_a6_partial',
recipients=config['notifications']['recipients']['errors'],
data={
'campaign_name': campaign_name,
'campaign_id': campaign_id,
'campaign_number': campaign_number,
'total_assets': len(not_approved_assets),
'successful': len(processed_assets),
'failed': len(failed_assets),
'rejected_assets': processed_assets, # Includes rejection_details
'failed_assets': failed_assets
}
)
return {'success': False, 'processed': len(processed_assets), 'failed': len(failed_assets)}
except Exception as e:
logger.error("Campaign processing failed: {}".format(str(e)))
return {'success': False, 'processed': 0, 'failed': 0}
def main():
"""Main polling loop"""
# Parse command-line arguments
parser = argparse.ArgumentParser(description='Ferrero A5→A6 Rework Asset Downloader')
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 A5→A6 Rework Asset Downloader 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 (pass mTLS flag to DAM, use Revisions Box folder)
dam = DAMClient(config, auth_mode=auth_mode)
box = BoxClient(config, root_folder_id='349441822875') # Revisions folder
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("")
# SINGLE RUN MODE - Process ONE campaign and exit
# Cron will run this script every 5 minutes, processing one campaign at a time
try:
logger.info("Searching for A5 campaigns...")
# Search for campaigns with status A5
campaigns = dam.search_campaigns(status='A5')
if not campaigns:
logger.info("No A5 campaigns found - exiting")
db.close()
sys.exit(0)
# Process ONLY THE FIRST campaign
campaign = campaigns[0]
logger.info("Found {} A5 campaigns - processing first one only".format(len(campaigns)))
logger.info("")
result = process_campaign(campaign, dam, box, db, notifier, config)
if result['success']:
logger.info("")
logger.info("=" * 60)
logger.info("✓ Campaign completed successfully")
logger.info(" Processed: {} assets".format(result['processed']))
# Only show status update if assets were actually processed
if result['processed'] > 0:
logger.info(" Status updated: A5 → A6")
else:
logger.info(" Status NOT updated (no rejected assets found)")
logger.info("=" * 60)
db.close()
sys.exit(0)
else:
logger.warning("")
logger.warning("=" * 60)
logger.warning("✗ Campaign incomplete or failed")
logger.warning(" Processed: {} assets".format(result['processed']))
logger.warning(" Failed: {} assets".format(result['failed']))
logger.warning(" Status NOT updated (remains A5)")
logger.warning("=" * 60)
db.close()
sys.exit(1)
except Exception as e:
logger.critical("Script error: {}".format(str(e)))
# Send critical error notification
notifier.send_email(
template_name='upload_failed',
recipients=config['notifications']['recipients']['critical'],
data={
'filename': 'A5→A6 Script',
'tracking_id': 'N/A',
'error': str(e)
}
)
db.close()
sys.exit(1)
if __name__ == '__main__':
main()