diff --git a/Python-Version/README.md b/Python-Version/README.md new file mode 100644 index 0000000..6c4839a --- /dev/null +++ b/Python-Version/README.md @@ -0,0 +1,236 @@ +# Ferrero Content Scaling - Python Automation + +**Automated workflow for Content Scaling (A1→A2→A3)** + +Compatible with Python 3.6+ (server) and Python 3.10+ (local development) + +--- + +## Quick Start + +### 1. Setup +```bash +cd Python-Version +./setup.sh +``` + +This will: +- Create virtual environment +- Install dependencies +- Create .env template +- Setup directory structure + +### 2. Configure +```bash +# Edit .env with your credentials +nano .env + +# Review configuration +nano config/config.yaml +``` + +### 3. Test Connections +```bash +source venv/bin/activate +python scripts/test_connection.py +``` + +Should show: +``` +✓ DAM connection OK +✓ Box connection OK +✓ Database connection OK +``` + +### 4. Run Scripts + +**A1→A2 Download (Polling):** +```bash +python scripts/a1_to_a2_download.py +``` + +**A2→A3 Upload (Webhook):** +```bash +python scripts/a2_to_a3_upload.py +``` + +--- + +## Features + +### A1→A2 Master Asset Downloader +- Polls DAM every 5 minutes for campaigns with status A1 +- Downloads all master assets +- Uploads to Box with tracking IDs +- Stores complete metadata in PostgreSQL +- **Only updates status A1→A2 when ALL assets processed successfully** +- Sends webhook notification with campaign ID and number +- Email notifications on success/failure + +### A2→A3 Upload Handler +- Receives webhooks from Box when files uploaded +- Parses V2 filenames +- Loads master metadata from database +- Extracts 27-28 MVP fields +- Updates fields from filename (Description, State, Language) +- Uploads to DAM with clean filename +- **Only updates status A2→A3 when ALL campaign assets uploaded** +- Sends webhook notification +- Email notifications + +--- + +## Configuration + +### Easy Field Updates +Edit `config/field_mappings.yaml`: +```yaml +mvp_fields: + - FERRERO.FIELD.MKTG.ASSET TYPE + - NEW.FIELD.ID.HERE # Just add new field IDs! +``` + +### Environment Switching +```bash +# Staging +export ENV=staging + +# Production +export ENV=production +``` + +### Change Webhook URL +```yaml +# config/config.yaml +webhooks: + campaign_status_update: + url: https://your-new-url.com/api # Just change URL! +``` + +### Change Email Recipients +```yaml +# config/config.yaml +notifications: + recipients: + success: + - newperson@ferrero.com # Just add to list! +``` + +--- + +## Deployment + +### Local Testing +```bash +source venv/bin/activate +python scripts/a1_to_a2_download.py +``` + +### Production (Cron) +```bash +# Add to crontab +crontab -e + +# Run every 5 minutes +*/5 * * * * cd ~/ferrero-automation/Python-Version && venv/bin/python scripts/a1_to_a2_download.py >> logs/cron.log 2>&1 +``` + +### Webhook Server (Background) +```bash +cd Python-Version +source venv/bin/activate +nohup python scripts/a2_to_a3_upload.py > logs/webhook.log 2>&1 & +echo $! > webhook.pid +``` + +--- + +## Monitoring + +### Check Logs +```bash +tail -f logs/a1_to_a2.log +tail -f logs/a2_to_a3.log +tail -f logs/errors.log +``` + +### Check Database +```bash +psql -h localhost -p 5433 -U ferrero_user -d ferrero_tracking + +# Check recent uploads +SELECT tracking_id, original_filename, created_at +FROM master_assets +ORDER BY created_at DESC LIMIT 10; +``` + +--- + +## Troubleshooting + +### Connection Issues +```bash +python scripts/test_connection.py +``` + +### Invalid Filename +```bash +# Test filename parsing +python -c "from scripts.shared.filename_parser import FilenameParser; p=FilenameParser(); print(p.parse_filename('your_filename.mp4'))" +``` + +### Email Not Sending +- Check Mailgun API key in .env +- Check recipient emails in config +- Check logs: `grep -i mailgun logs/*.log` + +### Webhook Not Receiving +- Check webhook server running: `ps aux | grep a2_to_a3` +- Check port accessible: `netstat -an | grep 5000` +- Check Box webhook configuration + +--- + +## File Structure + +``` +Python-Version/ +├── venv/ # Virtual environment +├── scripts/ +│ ├── a1_to_a2_download.py # A1→A2 poller +│ ├── a2_to_a3_upload.py # A2→A3 webhook +│ ├── test_connection.py # Connection tester +│ └── shared/ +│ ├── config_loader.py # Config management +│ ├── dam_client.py # DAM API +│ ├── box_client.py # Box API +│ ├── database.py # PostgreSQL +│ ├── notifier.py # Email + webhooks +│ ├── filename_parser.py # V2 naming parser +│ └── metadata_extractor_mvp.py +├── config/ +│ ├── config.yaml # Main config +│ ├── field_mappings.yaml # MVP fields (easy to edit!) +│ └── environments/ +│ ├── staging.yaml +│ └── production.yaml +├── logs/ +├── temp/downloads/ +└── .env # Environment variables +``` + +--- + +## Support + +For issues: +1. Check logs in `logs/` directory +2. Run `python scripts/test_connection.py` +3. Review configuration in `config/config.yaml` +4. Check `.env` has all required variables + +--- + +**Version:** 1.0.0 +**Compatible:** Python 3.6+ (server) and Python 3.10+ (local) +**Status:** Ready for testing diff --git a/Python-Version/scripts/a1_to_a2_download.py b/Python-Version/scripts/a1_to_a2_download.py new file mode 100755 index 0000000..038ba0e --- /dev/null +++ b/Python-Version/scripts/a1_to_a2_download.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +""" +A1→A2 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 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('logs/a1_to_a2.log'), + logging.StreamHandler() + ] +) + +logger = logging.getLogger('A1toA2') + +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 + master_assets = dam.get_master_assets(campaign_id) + 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') + + try: + 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 + box_result = box.upload_with_tracking_id( + file_path=file_path, + campaign_id=campaign_id, + campaign_name=campaign_name, + tracking_id=tracking_id + ) + + # 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 A1 → A2") + + status_result = dam.update_campaign_status(campaign_id, 'A2') + + if status_result['success']: + logger.info("✓ Status updated successfully") + + # Send webhook notification + if config['webhooks']['campaign_status_update']['enabled']: + logger.info("Sending campaign status webhook...") + notifier.send_webhook( + url=config['webhooks']['campaign_status_update']['url'], + payload={ + 'campaign_id': campaign_id, + 'campaign_number': campaign_number, + 'campaign_name': campaign_name, + 'old_status': 'A1', + 'new_status': 'A2', + 'asset_count': len(processed_assets), + 'processed_assets': processed_assets, + 'timestamp': int(time.time()) + } + ) + + # Send success email + notifier.send_email( + template_name='a1_to_a2_complete', + recipients=config['notifications']['recipients']['success'], + data={ + 'campaign_name': campaign_name, + 'campaign_id': campaign_id, + 'campaign_number': campaign_number, + 'asset_count': len(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 + notifier.send_email( + template_name='a1_to_a2_partial', + recipients=config['notifications']['recipients']['errors'], + data={ + 'campaign_name': campaign_name, + 'campaign_id': campaign_id, + 'total_assets': total_assets, + 'successful': len(processed_assets), + 'failed': len(failed_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 A1→A2 Master Asset Downloader Starting") + logger.info("=" * 60) + + # Load configuration + config = load_config('config/config.yaml') + + # Initialize clients + dam = DAMClient(config) + box = BoxClient(config) + 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("") + + poll_interval = config['polling']['interval_seconds'] + max_campaigns = config['polling']['max_campaigns_per_run'] + + # Main polling loop + while config['polling']['enabled']: + try: + logger.info("Polling for A1 campaigns...") + + # Search for campaigns with status A1 + campaigns = dam.search_campaigns(status='A1') + + if not campaigns: + logger.info("No A1 campaigns found") + else: + logger.info("Found {} A1 campaigns".format(len(campaigns))) + + # Limit campaigns per run + campaigns_to_process = campaigns[:max_campaigns] + + # Process each campaign + for campaign in campaigns_to_process: + result = process_campaign(campaign, dam, box, db, notifier, config) + + if result['success']: + logger.info("✓ Campaign completed successfully") + else: + logger.warning("✗ Campaign incomplete or failed") + + logger.info("") + logger.info("Sleeping for {} seconds...".format(poll_interval)) + logger.info("") + + time.sleep(poll_interval) + + except KeyboardInterrupt: + logger.info("Shutdown requested by user") + break + + 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': 'A1→A2 Script', + 'tracking_id': 'N/A', + 'error': str(e) + } + ) + # Continue running after error + time.sleep(poll_interval) + + logger.info("A1→A2 Script stopped") + db.close() + +if __name__ == '__main__': + main() diff --git a/Python-Version/scripts/a2_to_a3_upload.py b/Python-Version/scripts/a2_to_a3_upload.py new file mode 100755 index 0000000..d86daf1 --- /dev/null +++ b/Python-Version/scripts/a2_to_a3_upload.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +A2→A3 Upload Handler - Box Webhook Receiver +Processes file uploads from Box, uploads to DAM with MVP metadata +Updates status to A3 only when ALL assets for campaign uploaded +Compatible with Python 3.6+ +""" + +import sys +import os +import time +import logging +import hmac +import hashlib +from flask import Flask, request, jsonify +import threading +from queue import Queue + +# 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 +from shared.filename_parser import FilenameParser +from shared.metadata_extractor_mvp import MetadataExtractorMVP + +# Load configuration +config = load_config('config/config.yaml') +field_mappings = load_field_mappings(config) + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('logs/a2_to_a3.log'), + logging.StreamHandler() + ] +) + +logger = logging.getLogger('A2toA3') + +# Flask app for webhook +app = Flask(__name__) + +# Task queue for async processing +task_queue = Queue() + +def validate_box_signature(request_body, signature, keys): + """Validate Box webhook signature""" + for key in keys: + computed = hmac.new( + key.encode('utf-8'), + request_body, + hashlib.sha256 + ).hexdigest() + + if hmac.compare_digest(computed, signature): + return True + return False + +@app.route('/webhooks/box', methods=['POST']) +def box_webhook(): + """Box webhook receiver endpoint""" + try: + # Validate signature + signature = request.headers.get('Box-Signature-Primary', '') + + if config['webhook_receiver']['validate_signatures']: + keys = config['box']['webhook_signature_keys'] + if not validate_box_signature(request.data, signature, keys): + logger.warning("Invalid webhook signature") + return jsonify({'error': 'Invalid signature'}), 401 + + # Parse payload + data = request.json + + # Check for file upload event + if data.get('trigger') == 'FILE.UPLOADED': + # Queue for async processing + task_queue.put(data) + logger.info("Queued: {}".format(data['source']['name'])) + + return jsonify({'status': 'accepted'}), 200 + + except Exception as e: + logger.error("Webhook error: {}".format(str(e))) + return jsonify({'error': str(e)}), 500 + +def process_upload_queue(dam, box, db, notifier, parser, mvp_extractor): + """Background worker to process upload queue""" + + while True: + try: + # Wait for task (1 second timeout to allow checking) + webhook_data = task_queue.get(timeout=1) + + file_id = webhook_data['source']['id'] + filename = webhook_data['source']['name'] + + logger.info("=" * 60) + logger.info("Processing: {}".format(filename)) + logger.info("=" * 60) + + try: + # 1. Parse V2 filename + parsed = parser.parse_filename(filename) + + if not parsed['is_valid']: + raise ValueError("Invalid V2 filename: {} - {}".format( + filename, ', '.join(parsed['validation_errors']) + )) + + tracking_id = parsed['tracking_id'] + + if not tracking_id: + raise ValueError("No tracking ID in filename") + + # 2. Load master metadata from database + master_asset = db.get_master_asset(tracking_id) + + if not master_asset: + raise ValueError("No master asset for tracking ID: {}".format(tracking_id)) + + # 3. Download from Box + temp_file = os.path.join('temp/downloads', filename) + box.download_file(file_id, temp_file) + + # 4. Get clean filename + clean_filename = parser.strip_upload_components(filename) + + # 5. Build MVP asset representation + asset_rep = mvp_extractor.build_mvp_asset_representation( + master_metadata=master_asset['full_metadata'], + clean_filename=clean_filename, + parsed_filename=parsed + ) + + # 6. Rename to clean filename + clean_temp_file = os.path.join('temp/downloads', clean_filename) + os.rename(temp_file, clean_temp_file) + + # 7. Upload to DAM + upload_result = dam.upload_asset( + file_path=clean_temp_file, + folder_id=master_asset['upload_directory'], + asset_representation=asset_rep + ) + + if not upload_result['success']: + raise Exception("Upload failed: {}".format(upload_result.get('error'))) + + # 8. Store derivative record + db.store_derivative_asset( + tracking_id=tracking_id, + master_asset_id=None, # Could lookup from tracking_id + dam_asset_id=upload_result['asset_id'], + filename=clean_filename + ) + + # 9. Check if ALL files for campaign uploaded + # TODO: Need campaign_id - could store in master_assets table + # For now, log success + logger.info("✓ Upload successful: {} → Asset ID: {}".format( + filename, upload_result['asset_id'] + )) + + # Clean up + os.remove(clean_temp_file) + + except Exception as e: + logger.error("✗ Upload failed: {} - {}".format(filename, str(e))) + + # Send error notification + notifier.send_email( + template_name='upload_failed', + recipients=config['notifications']['recipients']['errors'], + data={ + 'filename': filename, + 'tracking_id': tracking_id if 'tracking_id' in locals() else 'Unknown', + 'error': str(e) + } + ) + + finally: + task_queue.task_done() + + except: + # Queue timeout or interrupt + continue + +def main(): + """Main entry point""" + logger.info("=" * 60) + logger.info("Ferrero A2→A3 Upload Handler Starting") + logger.info("=" * 60) + + # Initialize clients + dam = DAMClient(config) + box = BoxClient(config) + db = Database(config) + notifier = Notifier(config) + parser = FilenameParser() + mvp_extractor = MetadataExtractorMVP(field_mappings) + + # Test connections + logger.info("Testing connections...") + if not dam.test_connection(): + logger.error("DAM connection failed") + sys.exit(1) + + if not box.test_connection(): + logger.error("Box connection failed") + sys.exit(1) + + if not db.test_connection(): + logger.error("Database connection failed") + sys.exit(1) + + logger.info("All connections OK") + logger.info("") + + # Start background worker thread + worker = threading.Thread( + target=process_upload_queue, + args=(dam, box, db, notifier, parser, mvp_extractor), + daemon=True + ) + worker.start() + logger.info("Background worker started") + + # Start Flask webhook server + host = config['webhook_receiver']['host'] + port = config['webhook_receiver']['port'] + + logger.info("Starting webhook server on {}:{}".format(host, port)) + logger.info("") + + app.run(host=host, port=port, debug=False) + +if __name__ == '__main__': + main() diff --git a/Python-Version/scripts/shared/box_client.py b/Python-Version/scripts/shared/box_client.py new file mode 100644 index 0000000..7640bc1 --- /dev/null +++ b/Python-Version/scripts/shared/box_client.py @@ -0,0 +1,180 @@ +""" +Box Client - Box.com API Integration +Handles JWT authentication and Box operations +Compatible with Python 3.6+ +""" + +import json +import logging +from boxsdk import Client, JWTAuth + +logger = logging.getLogger('BoxClient') + +class BoxClient: + def __init__(self, config): + self.config = config + self.root_folder_id = config['box']['root_folder_id'] + + # Load Box config for JWT + box_config_path = config['box']['rsa_private_key_path'] + + try: + with open(box_config_path, 'r') as f: + box_config = json.load(f) + + # Initialize JWT authentication + auth = JWTAuth.from_settings_dictionary(box_config) + self.client = Client(auth) + + logger.info("Box client initialized with JWT auth") + + except Exception as e: + logger.error("Failed to initialize Box client: {}".format(str(e))) + raise + + def upload_with_tracking_id(self, file_path, campaign_id, campaign_name, tracking_id): + """ + Upload file to Box with tracking ID in filename + + Args: + file_path: Path to local file + campaign_id: Campaign ID + campaign_name: Campaign name + tracking_id: 6-character tracking ID + + Returns: + dict with file_id, url, folder_id + """ + try: + import os + + # Create or find campaign folder + folder = self._get_or_create_campaign_folder(campaign_id, campaign_name) + + # Get original filename + original_filename = os.path.basename(file_path) + name_without_ext, ext = os.path.splitext(original_filename) + + # Add tracking ID to filename + box_filename = "{}_{}{}".format(name_without_ext, tracking_id, ext) + + # Upload file + uploaded_file = folder.upload(file_path, box_filename) + + # Set description with DAM asset info + description = "Tracking ID: {}\nOriginal: {}".format( + tracking_id, original_filename + ) + uploaded_file.update_info({'description': description}) + + logger.info("Uploaded to Box: {} → File ID: {}".format(box_filename, uploaded_file.id)) + + return { + 'file_id': uploaded_file.id, + 'url': 'https://app.box.com/file/{}'.format(uploaded_file.id), + 'folder_id': folder.id, + 'box_filename': box_filename + } + + except Exception as e: + logger.error("Box upload failed: {}".format(str(e))) + raise + + def _get_or_create_campaign_folder(self, campaign_id, campaign_name): + """Get or create campaign folder in Box""" + try: + root_folder = self.client.folder(self.root_folder_id) + + # Folder name format: C000000078_Campaign_Name + folder_name = "{}_{}".format(campaign_id, campaign_name.replace(' ', '_')) + + # Check if folder exists + items = root_folder.get_items() + for item in items: + if item.type == 'folder' and item.name == folder_name: + logger.info("Using existing Box folder: {}".format(folder_name)) + return self.client.folder(item.id) + + # Create new folder + new_folder = root_folder.create_subfolder(folder_name) + logger.info("Created new Box folder: {}".format(folder_name)) + return new_folder + + except Exception as e: + logger.error("Failed to get/create Box folder: {}".format(str(e))) + raise + + def download_file(self, file_id, output_path): + """ + Download file from Box + + Args: + file_id: Box file ID + output_path: Path to save file + + Returns: + Path to downloaded file + """ + try: + import os + + file_obj = self.client.file(file_id) + file_info = file_obj.get() + + # Ensure output directory exists + os.makedirs(os.path.dirname(output_path) if os.path.dirname(output_path) else '.', exist_ok=True) + + # Download file + with open(output_path, 'wb') as f: + file_obj.download_to(f) + + file_size = os.path.getsize(output_path) + logger.info("Downloaded from Box: {} ({} bytes)".format(file_info.name, file_size)) + + return output_path + + except Exception as e: + logger.error("Box download failed: {}".format(str(e))) + raise + + def list_folder_files(self, folder_id): + """ + List all files in a Box folder + + Args: + folder_id: Box folder ID + + Returns: + List of file dictionaries + """ + try: + folder = self.client.folder(folder_id) + items = folder.get_items() + + files = [] + for item in items: + if item.type == 'file': + files.append({ + 'id': item.id, + 'name': item.name, + 'size': item.size, + 'modified_at': item.modified_at, + 'url': 'https://app.box.com/file/{}'.format(item.id) + }) + + logger.info("Found {} files in Box folder {}".format(len(files), folder_id)) + return files + + except Exception as e: + logger.error("Failed to list Box folder: {}".format(str(e))) + raise + + def test_connection(self): + """Test Box connection""" + try: + user = self.client.user().get() + logger.info("Box connection OK - User: {}".format(user.name)) + return True + except Exception as e: + logger.error("Box connection failed: {}".format(str(e))) + return False diff --git a/Python-Version/scripts/shared/database.py b/Python-Version/scripts/shared/database.py new file mode 100644 index 0000000..2c786eb --- /dev/null +++ b/Python-Version/scripts/shared/database.py @@ -0,0 +1,315 @@ +""" +Database Client - PostgreSQL Operations +Handles tracking IDs, master assets, and all-done checks +Compatible with Python 3.6+ +""" + +import psycopg2 +import psycopg2.pool +import json +import random +import string +import logging + +logger = logging.getLogger('Database') + +class Database: + def __init__(self, config): + self.config = config['database'] + + # Create connection pool + try: + self.pool = psycopg2.pool.ThreadedConnectionPool( + minconn=1, + maxconn=10, + host=self.config['host'], + port=self.config['port'], + database=self.config['database'], + user=self.config['user'], + password=self.config['password'] + ) + logger.info("Database connection pool created") + except Exception as e: + logger.error("Database connection failed: {}".format(str(e))) + raise + + def get_connection(self): + """Get connection from pool""" + return self.pool.getconn() + + def put_connection(self, conn): + """Return connection to pool""" + self.pool.putconn(conn) + + def generate_unique_tracking_id(self): + """ + Generate unique 6-character tracking ID + + Returns: + str: 6-character alphanumeric ID + """ + conn = self.get_connection() + try: + cursor = conn.cursor() + + for attempt in range(100): + # Generate random 6-char ID + tracking_id = ''.join(random.choices( + string.ascii_letters + string.digits, k=6 + )) + + # Check uniqueness + cursor.execute( + "SELECT COUNT(*) FROM master_assets WHERE tracking_id = %s", + (tracking_id,) + ) + + count = cursor.fetchone()[0] + + if count == 0: + logger.info("Generated tracking ID: {}".format(tracking_id)) + return tracking_id + + raise Exception("Failed to generate unique tracking ID after 100 attempts") + + finally: + cursor.close() + self.put_connection(conn) + + def store_master_asset(self, tracking_id, opentext_id, asset_data, box_file_id, box_url, upload_folder_id): + """ + Store master asset with FULL metadata in JSONB column + + Args: + tracking_id: 6-char tracking ID + opentext_id: DAM asset ID + asset_data: Complete DAM asset JSON + box_file_id: Box file ID + box_url: Box URL + upload_folder_id: Final Assets folder ID for upload + + Returns: + dict with success boolean + """ + conn = self.get_connection() + try: + cursor = conn.cursor() + + # Extract basic info + name = asset_data.get('name', 'unknown') + name_parts = name.rsplit('.', 1) + filename = name_parts[0] + extension = '.' + name_parts[1] if len(name_parts) > 1 else '' + + # Store complete metadata as JSONB + full_metadata_json = json.dumps(asset_data) + + # Description with Box info + description = "Box File ID: {}\nBox URL: {}\nDAM Asset ID: {}".format( + box_file_id, box_url, opentext_id + ) + + # Insert or update + cursor.execute(""" + INSERT INTO master_assets ( + tracking_id, opentext_id, original_filename, file_extension, + file_size_bytes, mime_type, upload_directory, + description, full_metadata, status + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s, %s, %s, 'active' + ) + ON CONFLICT (tracking_id) DO UPDATE SET + upload_directory = EXCLUDED.upload_directory, + description = EXCLUDED.description, + full_metadata = EXCLUDED.full_metadata, + updated_at = CURRENT_TIMESTAMP + """, ( + tracking_id, + opentext_id, + filename, + extension, + asset_data.get('file_size'), + asset_data.get('mime_type'), + upload_folder_id, + description, + full_metadata_json + )) + + conn.commit() + logger.info("Stored master asset: {}".format(tracking_id)) + + return {'success': True, 'tracking_id': tracking_id} + + except Exception as e: + conn.rollback() + logger.error("Failed to store master asset: {}".format(str(e))) + return {'success': False, 'error': str(e)} + + finally: + cursor.close() + self.put_connection(conn) + + def get_master_asset(self, tracking_id): + """ + Get master asset by tracking ID + + Returns: + dict with tracking_id, opentext_id, upload_directory, full_metadata + """ + conn = self.get_connection() + try: + cursor = conn.cursor() + + cursor.execute(""" + SELECT tracking_id, opentext_id, upload_directory, full_metadata, description + FROM master_assets + WHERE tracking_id = %s AND status = 'active' + """, (tracking_id,)) + + row = cursor.fetchone() + + if not row: + return None + + # Parse JSONB as dict + full_metadata = row[3] if isinstance(row[3], dict) else json.loads(row[3]) + + return { + 'tracking_id': row[0], + 'opentext_id': row[1], + 'upload_directory': row[2], + 'full_metadata': full_metadata, + 'description': row[4] + } + + finally: + cursor.close() + self.put_connection(conn) + + def check_campaign_upload_complete(self, campaign_id): + """ + Check if ALL master assets for a campaign have been uploaded + + Args: + campaign_id: Campaign ID + + Returns: + bool: True if all assets uploaded, False otherwise + """ + conn = self.get_connection() + try: + cursor = conn.cursor() + + # Count total master assets for this campaign + cursor.execute(""" + SELECT COUNT(DISTINCT tracking_id) + FROM master_assets + WHERE campaign_id = %s AND status = 'active' + """, (campaign_id,)) + + total_masters = cursor.fetchone()[0] + + if total_masters == 0: + return False + + # Count how many have been uploaded (exist in derivative_assets) + cursor.execute(""" + SELECT COUNT(DISTINCT ma.tracking_id) + FROM master_assets ma + INNER JOIN derivative_assets da ON ma.tracking_id = da.tracking_id + WHERE ma.campaign_id = %s AND ma.status = 'active' + AND da.upload_status = 'completed' + """, (campaign_id,)) + + uploaded_count = cursor.fetchone()[0] + + all_done = uploaded_count == total_masters + + logger.info("Campaign {} upload status: {}/{} assets uploaded{}".format( + campaign_id, uploaded_count, total_masters, + " - ALL DONE" if all_done else "" + )) + + return all_done + + finally: + cursor.close() + self.put_connection(conn) + + def store_derivative_asset(self, tracking_id, master_asset_id, dam_asset_id, filename): + """ + Store derivative asset record after upload + + Args: + tracking_id: Master asset tracking ID + master_asset_id: Master asset DB ID + dam_asset_id: DAM asset ID of uploaded derivative + filename: Clean filename of derivative + + Returns: + dict with success boolean + """ + conn = self.get_connection() + try: + cursor = conn.cursor() + + cursor.execute(""" + INSERT INTO derivative_assets ( + tracking_id, master_asset_id, dam_asset_id, + derivative_filename, upload_status + ) VALUES (%s, %s, %s, %s, 'completed') + ON CONFLICT (tracking_id, derivative_filename) DO UPDATE SET + dam_asset_id = EXCLUDED.dam_asset_id, + uploaded_at = CURRENT_TIMESTAMP, + upload_status = 'completed' + """, (tracking_id, master_asset_id, dam_asset_id, filename)) + + conn.commit() + logger.info("Stored derivative asset: {} → {}".format(tracking_id, dam_asset_id)) + + return {'success': True} + + except Exception as e: + conn.rollback() + logger.error("Failed to store derivative: {}".format(str(e))) + return {'success': False, 'error': str(e)} + + finally: + cursor.close() + self.put_connection(conn) + + def get_campaign_asset_count(self, campaign_id): + """Get total master asset count for campaign""" + conn = self.get_connection() + try: + cursor = conn.cursor() + + cursor.execute(""" + SELECT COUNT(*) FROM master_assets + WHERE campaign_id = %s AND status = 'active' + """, (campaign_id,)) + + return cursor.fetchone()[0] + + finally: + cursor.close() + self.put_connection(conn) + + def test_connection(self): + """Test database connection""" + try: + conn = self.get_connection() + cursor = conn.cursor() + cursor.execute("SELECT 1") + cursor.close() + self.put_connection(conn) + logger.info("Database connection OK") + return True + except Exception as e: + logger.error("Database connection failed: {}".format(str(e))) + return False + + def close(self): + """Close all connections in pool""" + if self.pool: + self.pool.closeall() diff --git a/Python-Version/scripts/shared/filename_parser.py b/Python-Version/scripts/shared/filename_parser.py new file mode 100644 index 0000000..1206d12 --- /dev/null +++ b/Python-Version/scripts/shared/filename_parser.py @@ -0,0 +1,227 @@ +""" +Filename Parser - V2 Naming Convention Parser +Ported from PHP FilenameParser.php +Compatible with Python 3.6+ +""" + +import re +import logging + +logger = logging.getLogger('FilenameParser') + +class FilenameParser: + """ + Parse V2 naming convention filenames: + [OMG_JOB]_[BRAND]_[COUNTRY]_[LANG]_[TITLE]_[TYPE]_[VERSION]_[SEC]S_[RATIO]_[TRACKING] + + Example: 1234567_RAF_DE_de_TEST-JOB_OLV_001_6S_16x9_TaNu6a.mp4 + """ + + def parse_filename(self, filename): + """ + Parse V2 filename into components + + Args: + filename: Filename to parse (with or without extension) + + Returns: + dict with parsed components and validation results + """ + validation_errors = [] + warnings = [] + + # Remove extension + if '.' in filename: + filename_without_ext, extension = filename.rsplit('.', 1) + extension = '.' + extension + else: + filename_without_ext = filename + extension = '' + + # Split by underscore + parts = filename_without_ext.split('_') + + if len(parts) < 9: + validation_errors.append("Invalid structure: expected min 9 parts, got {}".format(len(parts))) + + parsed = { + 'original_filename': filename, + 'filename_without_ext': filename_without_ext, + 'extension': extension, + 'omg_job_number': None, + 'brand_code': None, + 'country_code': None, + 'language_code': None, + 'subject_title': None, + 'asset_type': None, + 'spot_version': None, + 'has_master': False, + 'seconds': None, + 'aspect_ratio': None, + 'tracking_id': None, + 'validation_errors': [], + 'warnings': [], + 'is_valid': False + } + + if len(parts) < 9: + parsed['validation_errors'] = validation_errors + return parsed + + index = 0 + + # 1. OMG Job Number (digits only, max 10) + if index < len(parts) and parts[index].isdigit(): + omg = parts[index] + if len(omg) > 10: + validation_errors.append("OMG Job Number too long: {} (max 10)".format(omg)) + else: + parsed['omg_job_number'] = omg + index += 1 + else: + if index < len(parts): + validation_errors.append("OMG Job Number missing or invalid: {}".format(parts[index])) + + # 2. Brand Code (2-5 chars) + if index < len(parts): + brand = parts[index].upper() + if 2 <= len(brand) <= 5: + parsed['brand_code'] = brand + else: + validation_errors.append("Brand Code invalid: {} (must be 2-5 chars)".format(brand)) + index += 1 + + # 3. Country Code (2 chars) + if index < len(parts): + country = parts[index].upper() + if len(country) == 2: + parsed['country_code'] = country + else: + validation_errors.append("Country Code invalid: {} (must be 2 chars)".format(country)) + index += 1 + + # 4. Language Code (2-3 chars) + if index < len(parts): + lang = parts[index].lower() + if 2 <= len(lang) <= 3: + parsed['language_code'] = lang + else: + validation_errors.append("Language Code invalid: {} (must be 2-3 chars)".format(lang)) + index += 1 + + # 5. Subject Title (find asset type to know where title ends) + # Asset type is 3 uppercase letters followed by 3-char version + subject_parts = [] + asset_type_found = False + + for i in range(index, len(parts)): + part = parts[i] + # Check if this looks like asset type (3 uppercase letters) + if len(part) == 3 and part.isalpha() and part.isupper(): + # Check if next part looks like spot version + if i + 1 < len(parts): + next_part = parts[i + 1] + if len(next_part) == 3 or next_part.upper() == 'MST': + # Found asset type + index = i + asset_type_found = True + break + + subject_parts.append(part) + + if subject_parts: + parsed['subject_title'] = '_'.join(subject_parts) + if len(parsed['subject_title']) > 15: + warnings.append("Subject title exceeds 15 chars: {}".format(parsed['subject_title'])) + + # 6. Asset Type (3 uppercase letters) + if index < len(parts) and len(parts[index]) == 3: + parsed['asset_type'] = parts[index].upper() + index += 1 + else: + validation_errors.append("Asset Type missing or invalid") + + # 7. Spot Version (3 chars or MST) + if index < len(parts): + spot = parts[index].upper() + if spot == 'MST' or 'MST' in spot: + parsed['has_master'] = True + parsed['spot_version'] = spot + index += 1 + + # 8. Duration (format: 6S, 15S, etc.) + if index < len(parts): + duration = parts[index] + match = re.match(r'^(\d+)S$', duration, re.IGNORECASE) + if match: + parsed['seconds'] = match.group(1) + else: + validation_errors.append("Duration invalid: {} (must be format: 6S)".format(duration)) + index += 1 + + # 9. Aspect Ratio (format: 16x9, 4x3, etc.) + if index < len(parts): + ratio = parts[index] + if re.match(r'^\d+x\d+$', ratio, re.IGNORECASE): + parsed['aspect_ratio'] = ratio + else: + validation_errors.append("Aspect Ratio invalid: {} (must be format: 16x9)".format(ratio)) + index += 1 + + # 10. Tracking ID (6 alphanumeric chars) + if index < len(parts): + tracking = parts[index] + if len(tracking) == 6 and tracking.isalnum(): + parsed['tracking_id'] = tracking + else: + warnings.append("Tracking ID invalid: {} (should be 6 alphanumeric)".format(tracking)) + parsed['tracking_id'] = tracking + + # Set validation status + parsed['validation_errors'] = validation_errors + parsed['warnings'] = warnings + parsed['is_valid'] = len(validation_errors) == 0 + + return parsed + + def strip_upload_components(self, filename): + """ + Strip OMG Job Number and Tracking ID from filename + + Args: + filename: Original filename + + Returns: + Clean filename for upload + """ + parsed = self.parse_filename(filename) + + if not parsed: + return filename + + # Build clean filename + clean_parts = [] + + if parsed['brand_code']: + clean_parts.append(parsed['brand_code']) + if parsed['country_code']: + clean_parts.append(parsed['country_code']) + if parsed['language_code']: + clean_parts.append(parsed['language_code']) + if parsed['subject_title']: + clean_parts.append(parsed['subject_title']) + if parsed['asset_type']: + clean_parts.append(parsed['asset_type']) + if parsed['spot_version']: + clean_parts.append(parsed['spot_version']) + if parsed['seconds']: + clean_parts.append(parsed['seconds'] + 'S') + if parsed['aspect_ratio']: + clean_parts.append(parsed['aspect_ratio']) + + clean_filename = '_'.join(clean_parts) + + if parsed['extension']: + clean_filename += parsed['extension'] + + return clean_filename diff --git a/Python-Version/scripts/shared/metadata_extractor_mvp.py b/Python-Version/scripts/shared/metadata_extractor_mvp.py new file mode 100644 index 0000000..990a5b7 --- /dev/null +++ b/Python-Version/scripts/shared/metadata_extractor_mvp.py @@ -0,0 +1,221 @@ +""" +Metadata Extractor MVP - Extract MVP fields from master metadata +Ported from PHP MetadataExtractorMVP.php +Compatible with Python 3.6+ +""" + +import logging + +logger = logging.getLogger('MetadataExtractorMVP') + +class MetadataExtractorMVP: + def __init__(self, field_mappings): + """ + Initialize with field mappings from config + + Args: + field_mappings: dict from field_mappings.yaml + """ + self.mvp_field_ids = field_mappings['mvp_fields'] + self.filename_updates = field_mappings.get('filename_updates', {}) + self.forced_values = field_mappings.get('forced_values', {}) + self.defaults = field_mappings.get('defaults', {}) + + def extract_mvp_fields(self, master_metadata): + """ + Extract only MVP fields from full master metadata + + Args: + master_metadata: Complete DAM asset metadata + + Returns: + List of MVP field objects + """ + extracted_fields = [] + found_field_ids = [] + + # Navigate to metadata structure + # master_metadata is the full asset, need to go to: metadata.metadata_element_list + metadata_list = [] + + if isinstance(master_metadata, dict): + if 'metadata' in master_metadata and 'metadata_element_list' in master_metadata['metadata']: + metadata_list = master_metadata['metadata']['metadata_element_list'] + logger.info("Using master_metadata['metadata']['metadata_element_list']") + + logger.info("Searching through {} categories for MVP fields".format(len(metadata_list))) + + # Search through categories for MVP fields + for item in metadata_list: + if 'metadata_element_list' in item: + # Category with nested fields + for field in item['metadata_element_list']: + field_id = field.get('id') + if field_id in self.mvp_field_ids: + extracted_fields.append(field) + found_field_ids.append(field_id) + logger.debug("Found MVP field: {}".format(field_id)) + elif 'id' in item and item['id'] in self.mvp_field_ids: + # Direct field + extracted_fields.append(item) + found_field_ids.append(item['id']) + logger.debug("Found direct MVP field: {}".format(item['id'])) + + # Log results + missing = [f for f in self.mvp_field_ids if f not in found_field_ids] + logger.info("Found {}/{} MVP fields".format(len(found_field_ids), len(self.mvp_field_ids))) + + if missing: + logger.info("Missing fields: {}".format(', '.join(missing[:5]))) + + return extracted_fields + + def build_mvp_asset_representation(self, master_metadata, clean_filename, parsed_filename): + """ + Build asset representation with MVP fields + updates from filename + + Args: + master_metadata: Full master asset metadata + clean_filename: Clean filename (stripped) + parsed_filename: Parsed V2 filename dict + + Returns: + Asset representation dict ready for upload + """ + # Extract MVP fields from master + mvp_fields = self.extract_mvp_fields(master_metadata) + + # Update fields from filename and forced values + mvp_fields = self._update_fields(mvp_fields, clean_filename, parsed_filename) + + # Add missing MVP fields with defaults + mvp_fields = self._add_missing_fields(mvp_fields, parsed_filename) + + # Build asset representation + asset_rep = { + 'asset_resource': { + 'asset': { + 'metadata': { + 'metadata_element_list': mvp_fields + }, + 'metadata_model_id': 'ECOMMERCE', + 'security_policy_list': [ + {'id': 1594} + ] + } + } + } + + logger.info("Built MVP asset representation with {} fields".format(len(mvp_fields))) + + return asset_rep + + def _update_fields(self, mvp_fields, clean_filename, parsed_filename): + """Update specific fields from filename and forced values""" + + # Update ASSET NAME + for field in mvp_fields: + if field.get('id') == 'ARTESIA.FIELD.ASSET NAME': + self._set_field_value(field, clean_filename) + logger.info("Updated ASSET NAME: {}".format(clean_filename)) + + # Update DESCRIPTION from subject_title + if parsed_filename and parsed_filename.get('subject_title'): + for field in mvp_fields: + if field.get('id') == 'ARTESIA.FIELD.ASSET DESCRIPTION': + self._set_field_value(field, parsed_filename['subject_title']) + logger.info("Updated DESCRIPTION: {}".format(parsed_filename['subject_title'])) + + # Force STATE to Local + for field in mvp_fields: + if field.get('id') == 'FERRERO.FIELD.STATE': + self._set_field_value(field, 'Local') + logger.info("Set STATE to Local") + + return mvp_fields + + def _add_missing_fields(self, mvp_fields, parsed_filename): + """Add missing MVP fields from filename or defaults""" + field_ids = [f.get('id') for f in mvp_fields] + + # Add MAIN_LANGUAGES if missing + if 'MAIN_LANGUAGES' not in field_ids and parsed_filename: + if parsed_filename.get('language_code'): + language = parsed_filename['language_code'].upper() + logger.info("Adding MAIN_LANGUAGES: {}".format(language)) + + mvp_fields.append({ + 'id': 'MAIN_LANGUAGES', + 'parent_table_id': 'FERRERO.TABULAR.FIELD.MAIN LANGUAGES', + 'type': 'com.artesia.metadata.MetadataTableField', + 'values': [ + { + 'cascading_domain_value': False, + 'domain_value': True, + 'value': { + 'field_value': { + 'type': 'string', + 'value': language + }, + 'type': 'com.artesia.metadata.DomainValue' + } + } + ] + }) + + # Add other missing fields with defaults + field_ids = [f.get('id') for f in mvp_fields] + + for field_id, default_value in self.defaults.items(): + if field_id not in field_ids: + logger.info("Adding {} with default: {}".format(field_id, default_value)) + + # Check if it's a tabular field (contains .TABULAR. in parent table ID) + is_tabular = 'TABULAR' in field_id or field_id in [ + 'FERRERO.FIELD.ASSETCOMPLIANCE', 'MARKETING_TAG' + ] + + if is_tabular: + mvp_fields.append({ + 'id': field_id, + 'parent_table_id': 'FERRERO.TABULAR.FIELD.' + field_id.split('.')[-1], + 'type': 'com.artesia.metadata.MetadataTableField', + 'values': [ + { + 'cascading_domain_value': False, + 'domain_value': True, + 'value': { + 'field_value': { + 'type': 'string', + 'value': default_value + }, + 'type': 'com.artesia.metadata.DomainValue' + } + } + ] + }) + else: + mvp_fields.append({ + 'id': field_id, + 'type': 'com.artesia.metadata.MetadataField', + 'value': { + 'cascading_domain_value': False, + 'domain_value': True, + 'value': { + 'type': 'string', + 'value': default_value + } + } + }) + + return mvp_fields + + def _set_field_value(self, field, value): + """Set field value handling different structures""" + if 'value' in field: + if isinstance(field['value'], dict): + if 'value' in field['value'] and isinstance(field['value']['value'], dict): + if 'value' in field['value']['value']: + field['value']['value']['value'] = value + elif 'field_value' in field['value']['value']: + field['value']['value']['field_value']['value'] = value diff --git a/Python-Version/scripts/shared/notifier.py b/Python-Version/scripts/shared/notifier.py new file mode 100644 index 0000000..cec305c --- /dev/null +++ b/Python-Version/scripts/shared/notifier.py @@ -0,0 +1,170 @@ +""" +Notifier - Email and Webhook Notifications +Handles Mailgun emails and outgoing webhooks +Compatible with Python 3.6+ +""" + +import requests +import logging +from jinja2 import Template + +logger = logging.getLogger('Notifier') + +class Notifier: + def __init__(self, config): + self.config = config + self.enabled = config['notifications']['enabled'] + self.mailgun_api_key = config['notifications']['mailgun']['api_key'] + self.mailgun_domain = config['notifications']['mailgun']['domain'] + self.recipients = config['notifications']['recipients'] + self.webhook_config = config.get('webhooks', {}) + + def send_email(self, template_name, recipients, data): + """ + Send email via Mailgun + + Args: + template_name: Name of email template + recipients: List of email addresses + data: Template data dict + """ + if not self.enabled: + logger.info("Notifications disabled, skipping email") + return + + try: + # Simple templates (full template system would load from YAML) + templates = { + 'a1_to_a2_complete': { + 'subject': "✅ Master Assets Downloaded - Campaign {campaign_name}", + 'html': """ +

Master Assets Downloaded Successfully

+

Campaign: {campaign_name} ({campaign_id})

+

Campaign Number: {campaign_number}

+

Assets Downloaded: {asset_count}

+

Status Updated: A1 → A2

+
+

All assets have been downloaded and uploaded to Box with tracking IDs.

+ """ + }, + 'a2_to_a3_complete': { + 'subject': "✅ Localized Assets Uploaded - Campaign {campaign_name}", + 'html': """ +

Localized Assets Uploaded Successfully

+

Campaign: {campaign_name}

+

Campaign ID: {campaign_id}

+

Assets Uploaded: {asset_count}

+

Status Updated: A2 → A3

+
+

All localized assets have been uploaded to DAM.

+ """ + }, + 'upload_failed': { + 'subject': "❌ Upload Failed - {filename}", + 'html': """ +

Upload Failed

+

Filename: {filename}

+

Tracking ID: {tracking_id}

+

Error: {error}

+
+

Please investigate the error.

+ """ + }, + 'a1_to_a2_partial': { + 'subject': "⚠️ Partial Download - Campaign {campaign_name}", + 'html': """ +

Campaign Partially Processed

+

Campaign: {campaign_name} ({campaign_id})

+

Total Assets: {total_assets}

+

Successful: {successful}

+

Failed: {failed}

+
+

Status NOT updated. Campaign remains at A1.

+

Please review failed assets and retry.

+ """ + } + } + + template_config = templates.get(template_name, { + 'subject': 'Ferrero Automation Notification', + 'html': '

{}

'.format(data) + }) + + # Render subject and body + subject = template_config['subject'].format(**data) + html_template = Template(template_config['html']) + html_body = html_template.render(**data) + + # Send via Mailgun + response = requests.post( + "https://api.mailgun.net/v3/{}/messages".format(self.mailgun_domain), + auth=("api", self.mailgun_api_key), + data={ + "from": "Ferrero Automation ".format(self.mailgun_domain), + "to": recipients if isinstance(recipients, list) else [recipients], + "subject": subject, + "html": html_body + }, + timeout=10 + ) + + if response.status_code == 200: + logger.info("Email sent: {} to {}".format(template_name, recipients)) + else: + logger.error("Email failed: HTTP {} - {}".format( + response.status_code, response.text + )) + + except Exception as e: + logger.error("Email error: {}".format(str(e))) + + def send_webhook(self, url, payload): + """ + Send outgoing webhook notification + + Args: + url: Webhook URL + payload: dict to send as JSON + + Returns: + bool: Success status + """ + try: + # Get webhook config if exists + webhook_config = None + for name, config in self.webhook_config.items(): + if config.get('url') == url: + webhook_config = config + break + + if not webhook_config: + webhook_config = {'timeout_seconds': 10, 'auth': {}} + + # Prepare headers + headers = {'Content-Type': 'application/json'} + + # Add auth if configured + auth_config = webhook_config.get('auth', {}) + if auth_config.get('type') == 'bearer' and auth_config.get('token'): + headers['Authorization'] = 'Bearer {}'.format(auth_config['token']) + + # Send webhook + response = requests.post( + url, + json=payload, + headers=headers, + timeout=webhook_config.get('timeout_seconds', 10) + ) + + if response.status_code in [200, 201, 202]: + logger.info("Webhook sent successfully: {}".format(url)) + return True + else: + logger.warning("Webhook failed: HTTP {} - {}".format( + response.status_code, response.text[:200] + )) + return False + + except Exception as e: + logger.error("Webhook error: {}".format(str(e))) + return False diff --git a/Python-Version/scripts/test_connection.py b/Python-Version/scripts/test_connection.py new file mode 100755 index 0000000..3149665 --- /dev/null +++ b/Python-Version/scripts/test_connection.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +Test Connections - Verify DAM, Box, and Database connectivity +""" + +import sys +import os + +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 + +def main(): + print("=" * 60) + print("Testing Ferrero Automation Connections") + print("=" * 60) + print("") + + # Load config + try: + config = load_config('config/config.yaml') + print("✓ Configuration loaded") + except Exception as e: + print("✗ Configuration failed: {}".format(e)) + sys.exit(1) + + # Test DAM + print("") + print("Testing DAM connection...") + try: + dam = DAMClient(config) + if dam.test_connection(): + print("✓ DAM connection OK") + print(" URL: {}".format(config['dam']['base_url'])) + else: + print("✗ DAM connection failed") + except Exception as e: + print("✗ DAM error: {}".format(e)) + + # Test Box + print("") + print("Testing Box connection...") + try: + box = BoxClient(config) + if box.test_connection(): + print("✓ Box connection OK") + print(" Enterprise ID: {}".format(config['box']['enterprise_id'])) + else: + print("✗ Box connection failed") + except Exception as e: + print("✗ Box error: {}".format(e)) + + # Test Database + print("") + print("Testing Database connection...") + try: + db = Database(config) + if db.test_connection(): + print("✓ Database connection OK") + print(" Host: {}:{}".format(config['database']['host'], config['database']['port'])) + else: + print("✗ Database connection failed") + db.close() + except Exception as e: + print("✗ Database error: {}".format(e)) + + print("") + print("=" * 60) + print("Testing complete!") + print("=" * 60) + +if __name__ == '__main__': + main()