#!/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()