""" DAM Client - OpenText DAM API Integration Handles OAuth2 and mTLS certificate authentication Compatible with Python 3.6+ """ import requests import time import logging import os import urllib3 from contextlib import contextmanager from pathlib import Path from tempfile import NamedTemporaryFile # Disable SSL warnings when verify=False urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) logger = logging.getLogger('DAMClient') @contextmanager def pfx_to_pem(pfx_path, pfx_password): """ Convert PFX certificate to temporary PEM file for requests library Args: pfx_path: Path to PFX/P12 certificate file pfx_password: Certificate password Yields: str: Path to temporary PEM file """ try: from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, NoEncryption from cryptography.hazmat.primitives.serialization.pkcs12 import load_key_and_certificates # Load PFX file pfx = Path(pfx_path).read_bytes() private_key, main_cert, add_certs = load_key_and_certificates( pfx, pfx_password.encode('utf-8') if pfx_password else None, None ) # Create temporary PEM file with NamedTemporaryFile(suffix='.pem', delete=False) as t_pem: with open(t_pem.name, 'wb') as pem_file: # Write private key pem_file.write(private_key.private_bytes( Encoding.PEM, PrivateFormat.PKCS8, NoEncryption() )) # Write main certificate pem_file.write(main_cert.public_bytes(Encoding.PEM)) # Write additional certificates in chain for cert in add_certs: pem_file.write(cert.public_bytes(Encoding.PEM)) yield t_pem.name # Cleanup temporary file try: os.unlink(t_pem.name) except Exception: pass except Exception as e: logger.error("Failed to convert PFX to PEM: {}".format(str(e))) raise class DAMClient: def __init__(self, config, use_mtls=False, auth_mode='oauth'): """ Initialize DAM Client Args: config: Configuration dict use_mtls: Legacy boolean flag (deprecated, use auth_mode) auth_mode: Authentication mode ('oauth', 'mtls', 'mtls_v2') 'oauth': Standard OAuth2 (default) 'mtls': Legacy mTLS via APIM 'mtls_v2': Hybrid mTLS -> OAuth token -> Direct DAM """ self.timeout = config['dam']['timeout_seconds'] # Handle legacy flag if use_mtls: self.auth_mode = 'mtls' else: self.auth_mode = auth_mode if self.auth_mode == 'mtls': # Legacy mTLS - Uses APIM URL self.base_url = config['dam'].get('mtls_base_url', '').rstrip('/') self.mtls_cert_path = config['dam'].get('mtls_cert_path') self.mtls_cert_password = config['dam'].get('mtls_cert_password') if not self.base_url: raise Exception("mTLS base URL not configured (DAM_MTLS_BASE_URL)") if not self.mtls_cert_path or not os.path.exists(self.mtls_cert_path): raise Exception("mTLS cert path not found: {}".format(self.mtls_cert_path)) self.session = None self.session_token = None logger.info("🔒 Using mTLS authentication (Legacy APIM)") elif self.auth_mode == 'mtls_v2': # New mTLS V2 - Uses Direct DAM URL + OAuth via Cert self.base_url = config['dam']['base_url'].rstrip('/') # Direct URL self.mtls_oauth_url = config['dam'].get('mtls_oauth_url') self.mtls_cert_path = config['dam'].get('mtls_cert_path') self.mtls_cert_password = config['dam'].get('mtls_cert_password') if not self.mtls_oauth_url: raise Exception("mTLS OAuth URL not configured (DAM_MTLS_OAUTH_URL)") if not self.mtls_cert_path or not os.path.exists(self.mtls_cert_path): raise Exception("mTLS cert path not found: {}".format(self.mtls_cert_path)) self.access_token = None self.token_expiry = 0 logger.info("🔐 Using mTLS V2 authentication (Hybrid)") logger.info("OAuth Endpoint: {}".format(self.mtls_oauth_url)) else: # Standard OAuth2 self.base_url = config['dam']['base_url'].rstrip('/') self.auth_url = config['dam']['auth_url'] self.client_id = config['dam']['client_id'] self.client_secret = config['dam']['client_secret'] self.access_token = None self.token_expiry = 0 logger.info("🔑 Using OAuth2 authentication") logger.info("Base URL: {}".format(self.base_url)) def get_access_token(self): """Get OAuth2 access token with auto-refresh""" if self.access_token and time.time() < self.token_expiry: return self.access_token if self.auth_mode == 'mtls_v2': return self._get_oauth_token_via_mtls() else: return self._request_new_token() def _get_oauth_token_via_mtls(self): """Request OAuth token using Client Certificate (mTLS V2)""" try: logger.debug("Requesting OAuth token via mTLS from: {}".format(self.mtls_oauth_url)) with pfx_to_pem(self.mtls_cert_path, self.mtls_cert_password) as cert: # Use requests with cert to call OAuth endpoint response = requests.post( self.mtls_oauth_url, cert=cert, verify=True, timeout=30 ) logger.debug("OAuth response: HTTP {}".format(response.status_code)) if response.status_code == 200: data = response.json() self.access_token = data['access_token'] expires_in = data.get('expires_in', 3600) self.token_expiry = time.time() + expires_in - 60 logger.info("mTLS OAuth token obtained, expires in {}s".format(expires_in)) return self.access_token else: raise Exception("mTLS OAuth failed: HTTP {} - {}".format( response.status_code, response.text )) except Exception as e: logger.error("Failed to get mTLS OAuth token: {}".format(str(e))) raise def _request_new_token(self): """Request new OAuth2 token (Standard Flow)""" try: # Debug logging logger.debug("Requesting OAuth token from: {}".format(self.auth_url)) response = requests.post( self.auth_url, data={ 'grant_type': 'client_credentials', 'client_id': self.client_id, 'client_secret': self.client_secret }, headers={ 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, verify=False, timeout=30 ) if response.status_code == 200: data = response.json() self.access_token = data['access_token'] expires_in = data.get('expires_in', 3600) self.token_expiry = time.time() + expires_in - 60 return self.access_token else: raise Exception("OAuth failed: HTTP {} - {}".format( response.status_code, response.text )) except Exception as e: logger.error("Failed to get OAuth token: {}".format(str(e))) raise def _get_mtls_session(self): """Get or create mTLS session (Legacy Mode Only)""" if self.session is None: import requests as requests_lib self.session = requests_lib.Session() with pfx_to_pem(self.mtls_cert_path, self.mtls_cert_password) as cert: self.session.cert = cert self.session.verify = True try: login_url = self.base_url.replace('/otmmapi', '/otdsws/login') response = self.session.get(login_url, timeout=self.timeout) if response.status_code == 200 and 'OTDSTicket' in response.cookies: self.session_token = response.cookies['OTDSTicket'] except Exception: pass return self.session def _make_api_request(self, method, url, **kwargs): """Make API request with appropriate authentication""" # Verbose logging for all DAM API calls logger.info("=" * 80) logger.info("DAM API REQUEST") logger.info(" Method: {}".format(method)) logger.info(" URL: {}".format(url)) # Log headers (sanitize auth tokens) headers = kwargs.get('headers', {}) if headers: sanitized_headers = headers.copy() if 'Authorization' in sanitized_headers: sanitized_headers['Authorization'] = 'Bearer ***REDACTED***' logger.info(" Headers: {}".format(sanitized_headers)) # Log request body/data if present if 'json' in kwargs: logger.info(" JSON Payload: {}".format(kwargs['json'])) elif 'data' in kwargs: logger.info(" Data Payload: {}".format(kwargs['data'])) # Log query params if present if 'params' in kwargs: logger.info(" Query Params: {}".format(kwargs['params'])) logger.info("=" * 80) if self.auth_mode == 'mtls': # Legacy mTLS (APIM) with pfx_to_pem(self.mtls_cert_path, self.mtls_cert_password) as cert: import requests as requests_lib session = requests_lib.Session() session.cert = cert session.verify = True if self.session_token: if 'cookies' not in kwargs: kwargs['cookies'] = {} kwargs['cookies']['OTDSTicket'] = self.session_token response = session.request( method, url, timeout=kwargs.pop('timeout', self.timeout), **kwargs ) # Log response status logger.info("DAM API RESPONSE: {} - Status: {}".format(url, response.status_code)) return response else: # OAuth2 (Standard) OR mTLS V2 (Hybrid) # Both use Bearer token token = self.get_access_token() if 'headers' not in kwargs: kwargs['headers'] = {} kwargs['headers']['Authorization'] = 'Bearer {}'.format(token) response = requests.request( method, url, verify=False, # Disable SSL verification for OAuth/Hybrid timeout=kwargs.pop('timeout', self.timeout), **kwargs ) # Log response status logger.info("DAM API RESPONSE: {} - Status: {}".format(url, response.status_code)) return response def search_campaigns(self, status=None, campaign_type='Local Adaptation'): """ Search for campaigns with optional status filter Args: status: Content Scaling Status (A1, A2, B1, B2, etc.) or None for all campaign_type: 'Local Adaptation' for A-series or 'Global comm' for B-series Returns: List of campaign dictionaries """ try: import json as json_module import urllib.parse # Build search condition (like Postman collection) search_condition = { "search_condition_list": { "search_condition": [ { "type": "com.artesia.search.SearchScalarCondition", "metadata_field_id": "ARTESIA.FIELD.CONTAINER TYPE NAME", "relational_operator_id": "ARTESIA.OPERATOR.CHAR.CONTAINS", "value": "GLOBALCAMPAING", "left_paren": "(", "right_paren": ")" }, { "type": "com.artesia.search.SearchScalarCondition", "metadata_field_id": "FERRERO.FIELD.CAMPAIGN TYPE", "relational_operator_id": "ARTESIA.OPERATOR.CHAR.CONTAINS", "value": campaign_type, # 'Local Adaptation' or 'Global comm' "relational_operator": "and" } ] } } # URL encode search condition search_condition_str = json_module.dumps(search_condition) search_condition_encoded = urllib.parse.quote(search_condition_str) # Use GET with query parameters (matching Postman) url = "{}/v6/search/text?load_type=metadata&search_config_id=18&search_condition_list={}".format( self.base_url, search_condition_encoded ) response = self._make_api_request( 'GET', url, headers={'Accept': 'application/json'} ) if response.status_code != 200: raise Exception("Search failed: HTTP {} - {}".format( response.status_code, response.text[:200] )) data = response.json() all_campaigns_raw = [] # Extract asset list from response if 'search_result_resource' in data: # Try asset_list first (like PHP version) all_campaigns_raw = data['search_result_resource'].get('asset_list', []) # Fallback to search_result_element_list if not all_campaigns_raw: results = data['search_result_resource'].get('search_result', {}).get('search_result_element_list', []) all_campaigns_raw = [r.get('resource', {}) for r in results] logger.info("Found {} total campaigns in search".format(len(all_campaigns_raw))) # Extract campaign info and filter by status all_campaigns = [] for asset in all_campaigns_raw: campaign = self._extract_campaign_info(asset) # Debug log the status logger.debug("Campaign: {} - Status: {}".format( campaign.get('campaign_name', 'Unknown'), campaign.get('status', 'NO STATUS') )) # Filter by status if provided if status is None or campaign.get('status') == status: all_campaigns.append(campaign) logger.info("Found {} campaigns{}".format( len(all_campaigns), " with status {}".format(status) if status else "" )) return all_campaigns except Exception as e: logger.error("Failed to search campaigns: {}".format(str(e))) raise def _extract_campaign_info(self, asset): """Extract campaign information from asset data""" campaign = { 'asset_id': asset.get('asset_id'), 'campaign_name': asset.get('name'), # Try name field first 'campaign_id': None, 'status': None, 'brand': None, 'market': None } # Extract metadata fields if 'metadata' in asset and 'metadata_element_list' in asset['metadata']: for category in asset['metadata']['metadata_element_list']: if 'metadata_element_list' in category: for field in category['metadata_element_list']: field_id = field.get('id') value = self._extract_field_value(field) if field_id == 'ARTESIA.FIELD.NAME' or field_id == 'INER_NAME_GENERIC': campaign['campaign_name'] = value elif field_id == 'FERRERO.FIELD.CAMPAIGN ID' or field_id == 'FERRERO.FIELD.CAMPAIGN_ID': campaign['campaign_id'] = value elif field_id == 'CONTENT.SCALING.STATUS': campaign['status'] = value elif field_id == 'FERRERO.FIELD.CAMPAIGN_BRAND': campaign['brand'] = value elif field_id == 'FERRERO.FIELD.CAMPAIGN_MARKET': campaign['market'] = value return campaign def _extract_field_value(self, field): """Extract value from field structure""" if 'value' in field: val = field['value'] if isinstance(val, dict): if 'value' in val and isinstance(val['value'], dict): if 'value' in val['value']: return val['value']['value'] elif 'field_value' in val['value'] and 'value' in val['value']['field_value']: return val['value']['field_value']['value'] return None def get_master_assets(self, campaign_id, is_global=False): """ Get master assets from Master Assets folder (A1) or Final Assets folder (B1) Searches recursively through subfolders Args: campaign_id: Campaign folder asset ID is_global: True for B1 Global campaigns (use Final Assets), False for A1 Local (use Master Assets) Returns: List of asset dictionaries with full metadata, including 'folder_path' for subfolder structure """ try: # B1 Global campaigns use "05. Final Assets" folder # A1 Local campaigns use "01. Master Assets" folder if is_global: master_folder_id = self.find_final_assets_folder(campaign_id) logger.info("Looking for Final Assets folder (B1 Global campaign)") else: master_folder_id = self._find_master_assets_folder(campaign_id) logger.info("Looking for Master Assets folder (A1 Local campaign)") if not master_folder_id: raise Exception("Assets folder not found (tried {})".format( "Final Assets" if is_global else "Master Assets" )) # Recursively get all assets from folder and subfolders all_assets = self._get_assets_recursive(master_folder_id, folder_path="") logger.info("Found {} master assets (recursive) in campaign {}".format(len(all_assets), campaign_id)) return all_assets except Exception as e: logger.error("Failed to get master assets: {}".format(str(e))) raise def _get_assets_recursive(self, folder_id, folder_path=""): """ Recursively get all assets from a folder and its subfolders Args: folder_id: Folder asset ID to search folder_path: Current path (e.g., "Subfolder1/Subfolder2") Returns: List of assets with 'folder_path' attribute added """ assets = [] try: response = self._make_api_request( 'GET', "{}/v6/folders/{}/children?load_type=full".format( self.base_url, folder_id ), headers={'Accept': 'application/json'} ) if response.status_code != 200: logger.warning("Failed to get folder {} children: HTTP {}".format(folder_id, response.status_code)) return assets data = response.json() children = data.get('folder_children', {}).get('asset_list', []) for child in children: # Check if it's a folder or an asset asset_type = child.get('asset_type', {}) type_name = asset_type.get('name', '') if isinstance(asset_type, dict) else str(asset_type) if type_name.lower() == 'folder': # It's a folder - recurse into it folder_name = child.get('name', 'Unknown') subfolder_path = folder_path + "/" + folder_name if folder_path else folder_name logger.info("Recursing into subfolder: {}".format(subfolder_path)) sub_assets = self._get_assets_recursive(child['asset_id'], subfolder_path) assets.extend(sub_assets) else: # It's an asset - add it with folder path child['folder_path'] = folder_path assets.append(child) return assets except Exception as e: logger.error("Error in recursive asset search for folder {}: {}".format(folder_id, str(e))) return assets def is_asset_not_approved(self, asset_data): """ Check if asset has ECOMMERCE STATUS = "NOT APPROVED" Used for A5→A6 rework workflow to filter rejected assets Args: asset_data: Complete asset JSON with metadata Returns: bool: True if status is "NOT APPROVED", False otherwise """ try: metadata = asset_data.get('metadata', {}) metadata_elements = metadata.get('metadata_element_list', []) for category in metadata_elements: for element in category.get('metadata_element_list', []): if element.get('id') == 'FERRERO.FIELD.ECOMMERCE STATUS': status_value = self._extract_field_value(element) if status_value: # Case-insensitive comparison return status_value.strip().upper() == 'NOT APPROVED' return False except Exception as e: logger.error("Error checking NOT APPROVED status: {}".format(str(e))) return False def extract_rejection_details(self, asset_data): """ Extract all rejection comments from a NOT APPROVED asset Extracts details from Approver, Legal, and IA&CC reviewers Args: asset_data: Complete asset JSON with metadata Returns: dict with rejection details from all reviewers, or None if not rejected """ if not self.is_asset_not_approved(asset_data): return None metadata = asset_data.get('metadata', {}) metadata_elements = metadata.get('metadata_element_list', []) # Initialize rejection details structure details = { 'asset_id': asset_data.get('asset_id'), 'asset_name': asset_data.get('name'), 'status': 'NOT APPROVED', 'approver': { 'comment': None, 'certifier_name': None, 'date': None }, 'legal': { 'comment': None, 'certifier_name': None, 'date': None }, 'ia_cc': { 'comment': None, 'certifier_name': None, 'date': None } } # Extract all rejection fields for category in metadata_elements: for element in category.get('metadata_element_list', []): field_id = element.get('id', '') value = self._extract_field_value(element) # Approver rejection details if field_id == 'FERRERO.MARKETING.FIELD.CERTIFIER COMMENT': details['approver']['comment'] = value elif field_id == 'FERRERO.FIELD.ECOMMERCE CERTIFIER': details['approver']['certifier_name'] = value elif field_id == 'FERRERO.MARKETING.FIELD.APPROVAL DATE': details['approver']['date'] = value # Legal rejection details elif field_id == 'FERRERO.MARKETING.FIELD.LEGAL COMMENT': details['legal']['comment'] = value elif field_id == 'FERRERO.FIELD.LEGAL CERTIFER': # Note: typo in field ID details['legal']['certifier_name'] = value elif field_id == 'FERRERO.MARKETING.FIELD.LEGAL APPROVAL DATE': details['legal']['date'] = value # IA&CC rejection details elif field_id == 'FERRERO.MARKETING.FIELD.IA CC COMMENT': details['ia_cc']['comment'] = value elif field_id == 'FERRERO.MARKETING.FIELD.IA CERTIFIER': details['ia_cc']['certifier_name'] = value elif field_id == 'FERRERO.MARKETING.FIELD.IA CC APPROVAL DATE': details['ia_cc']['date'] = value return details def _find_master_assets_folder(self, campaign_id): """Find '01. Master Assets' folder in campaign""" try: response = self._make_api_request( 'GET', "{}/v6/folders/{}/children".format(self.base_url, campaign_id) ) if response.status_code != 200: return None data = response.json() folders = data.get('folder_children', {}).get('asset_list', []) for folder in folders: name = folder.get('name', '') if 'Master' in name and 'Assets' in name: return folder['asset_id'] return None except Exception as e: logger.error("Error finding Master Assets folder: {}".format(str(e))) return None def find_final_assets_folder(self, campaign_id): """Find '01. Final Assets' folder in campaign""" try: response = self._make_api_request( 'GET', "{}/v6/folders/{}/children".format(self.base_url, campaign_id) ) if response.status_code != 200: return None data = response.json() folders = data.get('folder_children', {}).get('asset_list', []) for folder in folders: name = folder.get('name', '') if 'Final' in name and 'Assets' in name: return folder['asset_id'] return None except Exception as e: logger.error("Error finding Final Assets folder: {}".format(str(e))) return None def download_asset(self, asset_id, output_dir='.'): """ Download asset content Args: asset_id: Asset ID to download output_dir: Directory to save file Returns: Path to downloaded file """ try: # Get asset metadata first to get filename metadata_response = self._make_api_request( 'GET', "{}/v6/assets/{}".format(self.base_url, asset_id) ) if metadata_response.status_code != 200: raise Exception("Failed to get asset metadata: HTTP {}".format( metadata_response.status_code )) asset_data = metadata_response.json() asset = asset_data.get('asset_resource', {}).get('asset', asset_data) filename = asset.get('name', 'download_{}'.format(asset_id)) # Download content content_response = self._make_api_request( 'GET', "{}/v6/assets/{}/contents".format(self.base_url, asset_id), stream=True ) if content_response.status_code != 200: raise Exception("Download failed: HTTP {}".format(content_response.status_code)) # Ensure output directory exists os.makedirs(output_dir, exist_ok=True) # Save file filepath = os.path.join(output_dir, filename) with open(filepath, 'wb') as f: for chunk in content_response.iter_content(chunk_size=8192): f.write(chunk) file_size = os.path.getsize(filepath) logger.info("Downloaded: {} ({} bytes)".format(filename, file_size)) return filepath except Exception as e: logger.error("Failed to download asset {}: {}".format(asset_id, str(e))) raise def upload_asset(self, file_path, folder_id, asset_representation, video_metadata=None): """ Upload asset to DAM Args: file_path: Path to file to upload folder_id: Parent folder ID asset_representation: Asset metadata JSON structure video_metadata: Optional video metadata (width, height, etc.) Returns: dict with success, asset_id, http_code """ try: filename = os.path.basename(file_path) mime_type = self._get_mime_type(file_path) # Build upload manifest file_info = { 'file_name': filename, 'file_type': mime_type } # Add video dimensions if available if video_metadata: if video_metadata.get('width', 0) > 0: file_info['width'] = video_metadata['width'] if video_metadata.get('height', 0) > 0: file_info['height'] = video_metadata['height'] upload_manifest = { 'upload_manifest': { 'master_files': [ {'file': file_info} ] } } # Prepare multipart upload import json files = { 'files': (filename, open(file_path, 'rb'), mime_type) } data = { 'asset_representation': json.dumps(asset_representation), 'parent_folder_id': folder_id, 'manifest': json.dumps(upload_manifest) } # Log asset representation being sent logger.info("Uploading: {}".format(filename)) logger.info(" Parent Folder ID: {}".format(folder_id)) logger.info("=" * 60) logger.info("FULL ASSET REPRESENTATION (JSON):") logger.info("=" * 60) logger.info(json.dumps(asset_representation, indent=2)) logger.info("=" * 60) logger.info(" Summary:") logger.info(" Model ID: {}".format(asset_representation.get('metadata_model_id', 'N/A'))) logger.info(" Security Policies: {}".format(len(asset_representation.get('security_policy_list', [])))) if 'metadata' in asset_representation: metadata_fields = asset_representation['metadata'].get('metadata_element_list', []) total_fields = sum(len(cat.get('metadata_element_list', [])) for cat in metadata_fields) logger.info(" Metadata Fields: {}".format(total_fields)) response = self._make_api_request( 'POST', "{}/v6/assets".format(self.base_url), data=data, files=files ) # Close file files['files'][1].close() http_code = response.status_code if http_code in [201, 202]: result_data = response.json() asset_id = None job_id = None if 'asset_resource_list' in result_data: # Direct response with asset ID asset_id = result_data['asset_resource_list']['asset_resource'][0]['asset']['asset_id'] logger.info("Upload successful (immediate): {} → Asset ID: {}".format(filename, asset_id)) elif 'job_handle' in result_data: # Async job response - store job_id, don't poll (too slow!) job_id = result_data['job_handle'].get('job_id') logger.info("Upload accepted (async): {} → Job ID: {}".format(filename, job_id)) logger.info("Note: Job processing in background. Asset ID can be found later by searching folder.") # Use job_id temporarily (can be updated later by searching folder) asset_id = job_id return { 'success': True, 'asset_id': asset_id, 'job_id': job_id, 'http_code': http_code, 'is_async': job_id is not None } else: error_msg = "Upload failed: HTTP {}".format(http_code) if response.text: try: error_data = response.json() if 'exception_body' in error_data: error_msg = error_data['exception_body'].get('message', error_msg) except: pass logger.error(error_msg) return { 'success': False, 'error': error_msg, 'http_code': http_code } except Exception as e: logger.error("Upload exception: {}".format(str(e))) return { 'success': False, 'error': str(e), 'http_code': 0 } def find_asset_by_filename_in_folder(self, folder_id, filename): """ Find asset in folder by filename (useful after async upload) Args: folder_id: Folder ID to search filename: Filename to find Returns: str: Asset ID if found, None otherwise """ try: response = self._make_api_request( 'GET', "{}/v6/folders/{}/children?load_type=full".format(self.base_url, folder_id), headers={'Accept': 'application/json'} ) if response.status_code == 200: data = response.json() assets = data.get('folder_children', {}).get('asset_list', []) for asset in assets: if asset.get('name') == filename: asset_id = asset.get('asset_id') logger.info("Found uploaded asset: {} → Asset ID: {}".format(filename, asset_id)) return asset_id logger.warning("Asset not found in folder: {}".format(filename)) return None else: logger.warning("Failed to search folder: HTTP {}".format(response.status_code)) return None except Exception as e: logger.error("Error searching for asset: {}".format(str(e))) return None def _poll_job_for_asset_id(self, job_id, filename, max_attempts=10, delay=2): """ Poll job status to get actual asset ID after async upload Args: job_id: Job ID from upload response filename: Filename being uploaded (for logging) max_attempts: Maximum polling attempts (default 10) delay: Seconds between attempts (default 2) Returns: str: Asset ID if found, None otherwise """ import time as time_module for attempt in range(max_attempts): try: # Wait before polling if attempt > 0: time_module.sleep(delay) # Get job status response = self._make_api_request( 'GET', "{}/v6/jobs/{}".format(self.base_url, job_id) ) if response.status_code == 200: job_data = response.json() # Check job status status = job_data.get('job', {}).get('status', {}).get('status', 'unknown') logger.info(" Job status (attempt {}): {}".format(attempt + 1, status)) # If completed, look for asset ID if status.lower() in ['completed', 'success', 'done']: # Try to find asset ID in response asset_id = None # Check various locations where asset ID might be if 'job' in job_data: job = job_data['job'] # Check result if 'result' in job: result = job['result'] if isinstance(result, dict): asset_id = result.get('asset_id') or result.get('created_asset_id') # Check created_assets if not asset_id and 'created_assets' in job: created = job['created_assets'] if isinstance(created, list) and len(created) > 0: asset_id = created[0].get('asset_id') elif isinstance(created, dict): asset_id = created.get('asset_id') # Check asset_list if not asset_id and 'asset_list' in job: assets = job['asset_list'] if isinstance(assets, list) and len(assets) > 0: asset_id = assets[0].get('asset_id') return asset_id elif status.lower() in ['failed', 'error']: logger.error("Job failed: {}".format(job_data.get('job', {}).get('status', {}).get('message', 'Unknown error'))) return None # Still running, continue polling else: logger.warning("Job status check failed: HTTP {}".format(response.status_code)) except Exception as e: logger.warning("Job polling error (attempt {}): {}".format(attempt + 1, str(e))) logger.warning("Max polling attempts reached ({}) for job {}".format(max_attempts, job_id)) return None def update_campaign_status(self, campaign_id, new_status): """ Update Content Scaling Status field Args: campaign_id: Campaign folder ID new_status: New status value (A2, A3, etc.) Returns: dict with success boolean """ try: payload = { "edited_folder": { "data": { "metadata": [ { "id": "CONTENT.SCALING.STATUS", "type": "com.artesia.metadata.MetadataField", "value": { "cascading_domain_value": False, "domain_value": True, "value": { "type": "string", "value": new_status } } } ] } } } response = self._make_api_request( 'PATCH', "{}/v6/folders/{}?lock_strategy=optimistic".format( self.base_url, campaign_id ), json=payload, headers={ 'Content-Type': 'application/json', 'Accept': 'application/json' } ) if response.status_code == 200: logger.info("Updated campaign {} status to {}".format(campaign_id, new_status)) return {'success': True} else: error_msg = "Status update failed: HTTP {}".format(response.status_code) logger.error(error_msg) return {'success': False, 'error': error_msg} except Exception as e: logger.error("Status update exception: {}".format(str(e))) return {'success': False, 'error': str(e)} def test_connection(self): """Test DAM connection with current auth method""" try: if self.auth_mode in ['mtls', 'mtls_v2']: # Test mTLS/Hybrid by making a simple API call # For mtls_v2, this will trigger _get_oauth_token_via_mtls() first response = self._make_api_request('GET', "{}/v6".format(self.base_url)) return response.status_code < 500 else: # Test OAuth2 by getting token token = self.get_access_token() return token is not None except Exception as e: logger.error("Connection test failed: {}".format(str(e))) return False def _get_mime_type(self, file_path): """Get MIME type for file""" import mimetypes mime_type, _ = mimetypes.guess_type(file_path) return mime_type or 'application/octet-stream' def get_or_create_subfolder_path(self, base_folder_id, subfolder_path): """ Create or find subfolder structure in DAM matching Box structure Args: base_folder_id: Base folder (e.g., "01. Final Assets") subfolder_path: Path like "Europe/Germany" Returns: str: Folder ID of the deepest subfolder Example: Base folder: "89fa..." (01. Final Assets) Path: "Europe/Germany" Creates: 01. Final Assets/Europe/Germany Returns: ID of "Germany" folder """ if not subfolder_path: return base_folder_id # Split path into parts parts = subfolder_path.split('/') current_folder_id = base_folder_id for folder_name in parts: logger.info("Looking for/creating folder: {}".format(folder_name)) # Check if exists existing = self._find_subfolder_by_name(current_folder_id, folder_name) if existing: current_folder_id = existing logger.info("Found existing folder: {} (ID: {})".format(folder_name, current_folder_id)) else: # Create it new_id = self._create_folder(current_folder_id, folder_name) if new_id: current_folder_id = new_id logger.info("Created folder: {} (ID: {})".format(folder_name, current_folder_id)) else: logger.error("Failed to create folder: {}".format(folder_name)) return base_folder_id # Return base folder if creation fails return current_folder_id def _find_subfolder_by_name(self, parent_folder_id, folder_name): """Find subfolder by name, return ID or None""" try: # Get folder contents - try different API endpoints endpoint = '/containers/{}/children'.format(parent_folder_id) response = self._make_request('GET', endpoint) if response.status_code == 200: data = response.json() # Look for folders in the response items = data.get('items', data.get('children', [])) for item in items: if item.get('type') == 'folder' and item.get('name') == folder_name: return item.get('id') return None except Exception as e: logger.warning("Error finding subfolder: {}".format(str(e))) return None def _create_folder(self, parent_folder_id, folder_name): """Create folder in DAM, return new folder ID""" try: endpoint = '/containers' payload = { 'name': folder_name, 'parent_id': parent_folder_id, 'type': 'folder' } response = self._make_request('POST', endpoint, json=payload) if response.status_code in [200, 201]: data = response.json() folder_id = data.get('id') or data.get('container_id') or data.get('folder_id') return folder_id else: logger.error("Failed to create folder: HTTP {} - {}".format( response.status_code, response.text[:200] if response.text else 'No response' )) return None except Exception as e: logger.error("Error creating folder: {}".format(str(e))) return None