creative-x-ferrero/core/mapping_resolver.py
DJP b20119b383 Add complete mapping system and automated Box.com monitoring service
Major Features:
- Complete Ferrero ↔ CreativeX mapping system with 93 brands
- Automated Box.com folder monitoring service
- Email notifications with score breakdowns
- Database integration for result storage

Mapping System (v2.0.0):
- mappings.json: 93 brand mappings, 44+ channel mappings
- core/mapping_resolver.py: Translates Ferrero codes to CreativeX format
- scripts/validate_mappings.py: Validation tool for brand/channel support
- scripts/generate_brand_mappings.py: Auto-mapping tool
- scripts/download_reports.py: Scorecard PDF download tool
- Updated scripts/upload.py: Integrated mapping validation
- Updated scripts/check_status.py: Added detailed score display with guidelines

Documentation:
- Updated README.md: Complete user guide with mapping system
- Updated STATUS.md: Production-ready status with test results
- MAPPINGS_GUIDE.md: Complete mapping documentation
- MAPPING_IMPLEMENTATION.md: Implementation summary
- BRAND_MAPPINGS_REVIEW.md: Brand mapping validation guide
- PRODUCTION_BRANDS_SUMMARY.md: Production brand catalog
- PRODUCTION_MAPPING_COMPLETE.md: Mapping completion summary

Automation Service (New):
- creativex-automation/: Complete automated Box monitoring service
- Monitors Box Ferrero-In folder (363284027140) for new files
- Automatically uploads to CreativeX
- Polls for completion (30 min intervals)
- Extracts scores and stores in PostgreSQL creativex_scores table
- Sends formatted emails to file uploader + daveporter@oliver.agency
- Moves processed files to Processed subfolder

Service Components:
- automation/box_monitor.py: Box folder monitoring with uploader detection
- automation/upload_processor.py: CreativeX upload integration
- automation/status_poller.py: CreativeX status polling
- automation/result_handler.py: Score extraction and email sending
- automation/orchestrator.py: Service coordination
- automation/processing_queue.py: JSON-based processing queue
- service.py: Main service entry point
- config.py: Service configuration loader
- requirements.txt: All dependencies
- deployment/systemd/: Systemd service unit file
- Updated shared/notifier.py: Added creativex_upload_complete and creativex_upload_failed templates

Testing:
- Supports --dry-run mode for configuration testing
- Supports --scan-once mode for Box folder testing
- Manual run mode for development/testing
- Comprehensive logging with rotation (10MB, 28 backups)

Database Integration:
- Uses existing creativex_scores table (no migrations needed)
- Compatible with existing Ferrero-Opentext workflows
- Stores full CreativeX API responses in JSONB

Email Templates:
- Matches Ferrero-Opentext styling (#9c27b0 purple for CreativeX)
- Includes score, tier, guidelines breakdown, scorecard URL
- Recipients: Box uploader + CC to daveporter@oliver.agency

Deployment:
- Runs locally for dev/testing
- Systemd service for production
- Auto-restart on failure
- Complete documentation in creativex-automation/README.md

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
2026-01-29 09:51:16 -05:00

309 lines
11 KiB
Python

"""
Mapping resolver between Ferrero naming conventions and Creative X API
"""
import json
from pathlib import Path
from typing import Optional, Dict, Any
class MappingResolver:
"""Resolves mappings between Ferrero codes and Creative X API values"""
def __init__(self, mappings_json_path: str):
"""
Initialize mapping resolver
Args:
mappings_json_path: Path to mappings.json file
Raises:
FileNotFoundError: If mappings.json doesn't exist
ValueError: If mappings.json has invalid structure
"""
self.mappings_path = Path(mappings_json_path)
if not self.mappings_path.exists():
raise FileNotFoundError(f"mappings.json not found at: {mappings_json_path}")
self.mappings = self._load_json()
def _load_json(self) -> dict:
"""Load JSON data from file"""
try:
with open(self.mappings_path, 'r', encoding='utf-8') as f:
return json.load(f)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in mappings.json: {e}")
def get_creativex_brand(self, ferrero_brand_code: str) -> Optional[Dict[str, Any]]:
"""
Map Ferrero brand code to Creative X brand information
Args:
ferrero_brand_code: Ferrero brand code (e.g., 'NUT', 'RAF')
Returns:
dict: {
'name': Creative X brand name,
'id': Creative X brand ID,
'ferrero_name': Original Ferrero brand name
}
None: If mapping doesn't exist
"""
brand_map = self.mappings.get('brand_mappings', {}).get(ferrero_brand_code.upper())
if not brand_map:
return None
return {
'name': brand_map.get('creativex_name'),
'id': brand_map.get('creativex_id'),
'ferrero_name': brand_map.get('ferrero_name')
}
def get_creativex_channel_mapping(self, ferrero_social_code: str) -> Optional[Dict[str, Any]]:
"""
Map Ferrero social media code to Creative X channel information
Args:
ferrero_social_code: Ferrero social code (e.g., 'FBS', 'IGF', 'YTB')
Returns:
dict: {
'channel': Creative X channel name (required),
'publisher': Creative X publisher (optional),
'placement': Creative X placement (optional),
'ad_format': Creative X ad format (optional),
'ferrero_name': Original Ferrero social media name
}
None: If mapping doesn't exist
"""
# Search through all channel mapping categories
channel_categories = self.mappings.get('channel_mappings', {})
for category_name, category_data in channel_categories.items():
# Skip comment fields
if category_name.startswith('_'):
continue
if ferrero_social_code.upper() in category_data:
mapping = category_data[ferrero_social_code.upper()]
return {
'channel': mapping.get('creativex_channel'),
'publisher': mapping.get('creativex_publisher'),
'placement': mapping.get('creativex_placement'),
'ad_format': mapping.get('creativex_ad_format'),
'ferrero_name': mapping.get('ferrero_name')
}
return None
def get_creativex_market(self, ferrero_country_code: str, country_name: str = None) -> Dict[str, Any]:
"""
Map Ferrero country code to Creative X market information
Args:
ferrero_country_code: Ferrero country code (e.g., 'DE', 'IT', 'GL')
country_name: Full country name from Ferrero data (optional)
Returns:
dict: {
'iso_code': ISO 3166-1 code (or None for 'Global'),
'name': Market name for Creative X API,
'direct_lookup': Boolean indicating if ISO code can be used directly
}
"""
country_upper = ferrero_country_code.upper()
# Check for special cases first
special_cases = self.mappings.get('country_mappings', {}).get('SPECIAL_CASES', {})
if country_upper in special_cases:
special = special_cases[country_upper]
return {
'iso_code': special.get('creativex_iso_code'),
'name': special.get('creativex_name'),
'direct_lookup': False,
'note': special.get('_note')
}
# For standard ISO codes, use the country name from Ferrero data
# Creative X API expects the full market name, not ISO code
return {
'iso_code': country_upper,
'name': country_name, # Use full country name for API
'direct_lookup': True
}
def is_brand_supported(self, ferrero_brand_code: str) -> bool:
"""
Check if a Ferrero brand is supported in Creative X
Args:
ferrero_brand_code: Ferrero brand code
Returns:
bool: True if brand is mapped
"""
return ferrero_brand_code.upper() in self.mappings.get('brand_mappings', {})
def is_channel_supported(self, ferrero_social_code: str) -> bool:
"""
Check if a Ferrero social media code is supported in Creative X
Args:
ferrero_social_code: Ferrero social media code
Returns:
bool: True if channel is mapped
"""
return self.get_creativex_channel_mapping(ferrero_social_code) is not None
def get_all_supported_brands(self) -> Dict[str, str]:
"""
Get all supported brand mappings
Returns:
dict: {ferrero_code: creativex_name}
"""
brand_mappings = self.mappings.get('brand_mappings', {})
return {
code: data['creativex_name']
for code, data in brand_mappings.items()
if not code.startswith('_')
}
def get_all_supported_channels(self) -> Dict[str, str]:
"""
Get all supported channel mappings
Returns:
dict: {ferrero_code: creativex_channel}
"""
result = {}
channel_categories = self.mappings.get('channel_mappings', {})
for category_name, category_data in channel_categories.items():
if category_name.startswith('_'):
continue
for code, mapping in category_data.items():
result[code] = mapping.get('creativex_channel')
return result
def build_creativex_payload(self, parsed_metadata: Dict[str, Any]) -> Dict[str, Any]:
"""
Build Creative X preflight payload from parsed Ferrero metadata
Args:
parsed_metadata: Parsed metadata from filename parser
Returns:
dict: Creative X API payload structure
Raises:
ValueError: If required mappings are missing
"""
# Map brand
brand_mapping = self.get_creativex_brand(parsed_metadata.get('brand_code', ''))
if not brand_mapping:
raise ValueError(
f"Brand '{parsed_metadata.get('brand_code')}' is not supported in Creative X. "
f"Supported brands: {list(self.get_all_supported_brands().keys())}"
)
# Map channel
channel_mapping = self.get_creativex_channel_mapping(parsed_metadata.get('social_media', ''))
if not channel_mapping:
raise ValueError(
f"Social media code '{parsed_metadata.get('social_media')}' is not mapped to Creative X. "
f"Check mappings.json for supported channels."
)
# Map market - pass country name for proper lookup
market_mapping = self.get_creativex_market(
parsed_metadata.get('country_code', ''),
parsed_metadata.get('country_name', '')
)
# Build payload
payload = {
'brand_name': brand_mapping['name'],
'channel': channel_mapping['channel']
}
# Add optional channel fields
if channel_mapping.get('publisher'):
payload['publisher'] = channel_mapping['publisher']
if channel_mapping.get('placement'):
payload['placement'] = channel_mapping['placement']
if channel_mapping.get('ad_format'):
payload['ad_format'] = channel_mapping['ad_format']
# Add market name (always required by Creative X API)
payload['market_name'] = market_mapping['name']
# Add other metadata
if parsed_metadata.get('language_code'):
payload['language'] = parsed_metadata['language_code'].lower()
if parsed_metadata.get('aspect_ratio'):
payload['dimensions'] = parsed_metadata['aspect_ratio']
if parsed_metadata.get('seconds'):
# Remove 'S' suffix if present
duration = parsed_metadata['seconds'].upper().replace('S', '')
payload['duration'] = duration
if parsed_metadata.get('subject'):
payload['subject'] = parsed_metadata['subject']
if parsed_metadata.get('asset_type'):
payload['asset_type'] = parsed_metadata['asset_type']
return payload
def validate_metadata_for_upload(self, parsed_metadata: Dict[str, Any]) -> tuple[bool, list[str]]:
"""
Validate that parsed metadata can be mapped to Creative X
Args:
parsed_metadata: Parsed metadata from filename parser
Returns:
tuple: (is_valid: bool, errors: List[str])
"""
errors = []
# Check brand
brand_code = parsed_metadata.get('brand_code', '')
if not self.is_brand_supported(brand_code):
supported = ', '.join(self.get_all_supported_brands().keys())
errors.append(
f"Brand code '{brand_code}' not supported in Creative X. "
f"Supported: {supported}"
)
# Check channel
social_code = parsed_metadata.get('social_media', '')
if not self.is_channel_supported(social_code):
errors.append(
f"Social media code '{social_code}' not mapped to Creative X channel. "
f"Check mappings.json for available mappings."
)
# Country codes are mostly standard, so we don't strictly validate
# But we can warn about GL (Global) ambiguity
country_code = parsed_metadata.get('country_code', '')
if country_code.upper() == 'GL':
market_mapping = self.get_creativex_market(country_code)
if market_mapping.get('note'):
# This is informational, not an error
pass
is_valid = len(errors) == 0
return is_valid, errors