ferrero-opentext/Python-Version/scripts/b1_to_b2_download.py
DJP 055fc9ad16 Add recursive folder search, NOT APPROVED filtering, and rejection details for A5→A6
Major enhancements to all workflow scripts with recursive search and detailed rejection tracking.

NEW FEATURES:
1. Recursive Folder Search (ALL workflows: A1→A2, A5→A6, B1→B2)
   - Searches subfolders within Master/Final Assets folders
   - Preserves folder structure in Box
   - Adds 'folder_path' attribute to each asset

2. NOT APPROVED Filtering (A5→A6 ONLY)
   - Only downloads assets with ECOMMERCE STATUS = "NOT APPROVED"
   - Skips approved/other status assets
   - Logs rejected vs skipped counts

3. Rejection Details Extraction (A5→A6)
   - Extracts comments from 3 reviewers: Approver, Legal, IA&CC
   - Includes certifier names and dates
   - Displays in detailed email notifications

CHANGES BY FILE:

dam_client.py:
- NEW: _get_assets_recursive() - Recursively searches folders
- UPDATED: get_master_assets() - Now uses recursive search, adds folder_path to assets
- NEW: is_asset_not_approved() - Checks FERRERO.FIELD.ECOMMERCE STATUS
- NEW: extract_rejection_details() - Extracts all rejection comments from 10 fields

box_client.py:
- UPDATED: upload_with_tracking_id() - Added subfolder_path parameter
- NEW: _get_or_create_subfolder_path() - Creates/navigates Box subfolders
- Preserves DAM folder structure in Box uploads

a1_to_a2_download.py:
- Added folder_path extraction from assets
- Pass subfolder_path to Box upload
- Logs subfolder info during processing

b1_to_b2_download.py:
- Added folder_path extraction from assets
- Pass subfolder_path to Box upload
- Logs subfolder info during processing

a5_to_a6_download.py:
- Filter assets for NOT APPROVED status ONLY
- Extract rejection details for each asset
- Pass subfolder_path to Box upload
- Updated email data with rejection_details
- Handle "no rejections" scenario with email
- Updated logging to show rejected vs skipped counts

notifier.py:
- REPLACED: a5_to_a6_complete → a5_to_a6_rejections
- Detailed HTML template with rejection sections
- Shows Approver, Legal, and IA&CC rejections
- Styled with red warnings and bordered sections
- NEW: a5_to_a6_no_rejections template
- Green success message when no rejected assets found
- UPDATED: a5_to_a6_partial - Now uses rejected_assets

FIELD IDs EXTRACTED (A5→A6):
- FERRERO.FIELD.ECOMMERCE STATUS (primary check)
- FERRERO.MARKETING.FIELD.CERTIFIER COMMENT
- FERRERO.FIELD.ECOMMERCE CERTIFIER
- FERRERO.MARKETING.FIELD.APPROVAL DATE
- FERRERO.MARKETING.FIELD.LEGAL COMMENT
- FERRERO.FIELD.LEGAL CERTIFER (typo in field ID)
- FERRERO.MARKETING.FIELD.LEGAL APPROVAL DATE
- FERRERO.MARKETING.FIELD.IA CC COMMENT
- FERRERO.MARKETING.FIELD.IA CERTIFIER
- FERRERO.MARKETING.FIELD.IA CC APPROVAL DATE

TESTING:
✓ All connections working (DAM, Box, Database)
✓ A5→A6 script executes correctly
✓ Recursive search working
✓ NOT APPROVED filtering working
✓ "No rejections" email sent successfully
✓ Folder structure preserved in logs

WORKFLOW IMPACTS:
- A1→A2: Now searches recursively, preserves folder structure
- A5→A6: Filters for NOT APPROVED only, shows rejection details
- B1→B2: Now searches recursively, preserves folder structure

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 14:49:19 -05:00

314 lines
12 KiB
Python
Executable file

#!/usr/bin/env python3
"""
B1→B2 Master Asset Downloader
Polls DAM for campaigns with status A1, downloads master assets, uploads to Box
Updates status to A2 only when ALL assets successfully processed
Compatible with Python 3.6+
"""
import sys
import os
import time
import logging
# 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/b1_to_b2.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('B1toB2')
def process_campaign(campaign, dam, box, db, notifier, config):
"""
Process single campaign - download all master 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 master assets (B1 campaigns use Final Assets folder, is_global=True)
master_assets = dam.get_master_assets(campaign_id, is_global=True)
total_assets = len(master_assets)
logger.info("Found {} master assets".format(total_assets))
if total_assets == 0:
logger.warning("No master assets found, skipping campaign")
return {'success': False, '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': total_assets}
# Process each asset
for asset in master_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: {} (from subfolder: {})".format(asset_name, folder_path))
else:
logger.info("Processing: {}".format(asset_name))
# 1. Download from DAM
file_path = dam.download_asset(
asset_id,
output_dir='temp/downloads/{}'.format(campaign_id)
)
# 2. Generate tracking ID
tracking_id = db.generate_unique_tracking_id()
# 3. Upload to Box (B1 uses MASTERS_CampaignNumber format, preserve folder structure)
# If campaign_number exists, use it; otherwise use MASTERS prefix only
box_campaign_id = "MASTERS_{}".format(campaign_number) if campaign_number and campaign_number != 'N/A' else "MASTERS"
box_result = box.upload_with_tracking_id(
file_path=file_path,
campaign_id=box_campaign_id, # MASTERS_C000000068
campaign_name=campaign_name.replace(' ', '_').replace('-', '_'), # NUTELLA_PLANT_BASED_LAUNCH
tracking_id=tracking_id,
subfolder_path=folder_path # Preserve DAM folder structure
)
# Result: MASTERS_C000000068-NUTELLA_PLANT_BASED_LAUNCH/[subfolder_path]
# 4. Store in database with FULL metadata
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
)
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']
})
logger.info("✓ Success: {}{}".format(asset_name, tracking_id))
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 assets processed successfully?
all_done = len(processed_assets) == total_assets
logger.info("")
logger.info("Campaign {} Results:".format(campaign_id))
logger.info(" Total: {}".format(total_assets))
logger.info(" Successful: {}".format(len(processed_assets)))
logger.info(" Failed: {}".format(len(failed_assets)))
logger.info(" All Done: {}".format("YES" if all_done else "NO"))
logger.info("")
if all_done:
# ALL assets processed - update status
logger.info("All assets processed - Updating status B1 → B2")
status_result = dam.update_campaign_status(campaign_id, "B2")
if status_result['success']:
logger.info("✓ Status updated successfully")
# NOTE: B1→B2 workflow does NOT send webhook (only email notification)
# Webhook is only used for A1→A2 workflow
# Send success email with asset details
notifier.send_email(
template_name='b1_to_b2_complete',
recipients=config['notifications']['recipients']['success'],
data={
'campaign_name': campaign_name,
'campaign_id': campaign_id,
'campaign_number': campaign_number,
'asset_count': len(processed_assets),
'processed_assets': processed_assets
}
)
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 A1)")
# Send partial completion email with asset details
notifier.send_email(
template_name='b1_to_b2_partial',
recipients=config['notifications']['recipients']['errors'],
data={
'campaign_name': campaign_name,
'campaign_id': campaign_id,
'campaign_number': campaign_number,
'total_assets': total_assets,
'successful': len(processed_assets),
'failed': len(failed_assets),
'processed_assets': processed_assets,
'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': total_assets}
def main():
"""Main polling loop"""
logger.info("=" * 60)
logger.info("Ferrero B1→B2 Master Asset Downloader Starting")
logger.info("=" * 60)
# Load configuration
config = load_config('config/config.yaml')
# Initialize clients
dam = DAMClient(config)
# Use B1→B2 specific Box folder (349261192115)
box = BoxClient(config, root_folder_id=config['box'].get('root_folder_b1_b2'))
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 B1 Global campaigns...")
# Search for campaigns with status B1 (Global comm campaigns)
campaigns = dam.search_campaigns(status="B1", campaign_type="Global comm")
if not campaigns:
logger.info("No B1 campaigns found - exiting")
db.close()
sys.exit(0)
# Process ONLY THE FIRST campaign
campaign = campaigns[0]
logger.info("Found {} A1 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: B1 → B2")
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 A1)")
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': 'B1→B2 Script',
'tracking_id': 'N/A',
'error': str(e)
}
)
db.close()
sys.exit(1)
if __name__ == '__main__':
main()