1173 lines
45 KiB
Python
1173 lines
45 KiB
Python
"""
|
|
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
|