ferrero-opentext/Python-Version/scripts/a5_to_a6_download.py
DJP 8e7ae7e2d2 Add optional mTLS certificate authentication with --auth-pfx flag
Implements dual authentication system: OAuth2 (default) + mTLS (opt-in).
Zero-risk implementation - existing OAuth2 workflows unchanged.

NEW FEATURE: mTLS Certificate Authentication
- PFX/P12 certificate support for enhanced security
- Activated ONLY with --auth-pfx command-line flag
- OAuth2 remains default (no flag = OAuth2 as before)
- Perfect for testing new auth without breaking production

USAGE:
  Default (OAuth2):
    python scripts/a1_to_a2_download.py

  With mTLS:
    python scripts/a1_to_a2_download.py --auth-pfx

IMPLEMENTATION:

1. Certificate Storage (SECURE):
   - NEW: config/certificates/ folder (gitignored)
   - Moved PFX file to secure location
   - File permissions: 600 (owner read/write only)
   - Password stored in .env (already gitignored)

2. Configuration:
   - .env: Added DAM_MTLS_CERT_PATH and DAM_MTLS_CERT_PASSWORD
   - config.yaml: Added mtls_cert_path and mtls_cert_password
   - .gitignore: Added config/certificates/, *.pfx, *.p12

3. DAM Client Dual Auth:
   - NEW: pfx_to_pem() - Converts PFX to temporary PEM for requests
   - UPDATED: __init__() - Accepts use_mtls flag
   - NEW: _make_api_request() - Unified request wrapper
   - Auto-selects auth method based on flag
   - Updated ALL 8 API calls to use wrapper

4. Scripts Updated (argparse):
   - test_connection.py - Added --auth-pfx flag
   - a1_to_a2_download.py - Added --auth-pfx flag
   - a5_to_a6_download.py - Added --auth-pfx flag
   - b1_to_b2_download.py - Added --auth-pfx flag

5. Test Script:
   - NEW: test_mtls_cert.py - Standalone cert loading test
   - Tests PFX→PEM conversion without API calls
   - Verifies certificate format and cleanup

TESTING RESULTS:
✓ Certificate loads successfully (10930 bytes)
✓ PFX→PEM conversion works (13520 bytes)
✓ Temp file cleanup working
✓ OAuth2 connection test: PASS
✓ mTLS connection test: PASS
✓ Both auth methods working independently

SECURITY:
✓ Certificate file gitignored
✓ Password in .env (gitignored)
✓ File permissions: 600
✓ Temp PEM files auto-deleted
✓ No secrets in code or config

MIGRATION PATH:
- Dev: Use dam-mtls-dev.pfx (current)
- Prod: Replace cert file, update password, same code

BACKWARD COMPATIBILITY:
✓ OAuth2 still default (100% backward compatible)
✓ Existing cron jobs unchanged
✓ No breaking changes
✓ Easy rollback (just don't use --auth-pfx)

Changes:
- .gitignore (+3 lines)
- Python-Version/.env (+3 lines)
- Python-Version/config/config.yaml (+3 lines)
- Python-Version/scripts/shared/dam_client.py (+100 lines dual auth)
- Python-Version/scripts/a1_to_a2_download.py (+14 lines argparse)
- Python-Version/scripts/a5_to_a6_download.py (+14 lines argparse)
- Python-Version/scripts/b1_to_b2_download.py (+14 lines argparse)
- Python-Version/scripts/test_connection.py (+15 lines argparse)
- NEW: Python-Version/scripts/test_mtls_cert.py (92 lines)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 18:01:23 -05:00

379 lines
15 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, send email and exit
if len(not_approved_assets) == 0:
logger.info("No NOT APPROVED assets found - all assets are approved")
# Send "no rejections" email
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")
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)
# 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 instead of OAuth2')
args = parser.parse_args()
logger.info("=" * 60)
logger.info("Ferrero A5→A6 Rework Asset Downloader Starting")
if args.auth_pfx:
logger.info("Authentication: mTLS Certificate (--auth-pfx)")
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, use_mtls=args.auth_pfx)
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']))
logger.info(" Status updated: A5 → A6")
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()