Feature: Apply naming-tool pre-upload metadata overrides on A2→A3 upload

The naming tool's metadata editor saves pre-upload overrides to the
override_metadata table (shared ferrero_tracking DB), but until now the
Python upload pipeline never read from it — every edit was being saved
but never applied to DAM. This wires up the consumer side so user edits
land on the uploaded asset.

- database.py: get_override_metadata() / mark_override_applied(),
  resilient to a missing override_metadata table on dev DBs
- metadata_extractor_mvp.py: OVERRIDE_FIELD_MAP (mirrors the naming
  tool's editor-field → DAM-field-ID map) + _apply_override_fields().
  Applied after master/filename/forced/CreativeX values but before
  asset_type_overrides so EOL/LTD compliance still wins. Empty editor
  values are skipped (leaves inherited value alone). Validity ISO
  dates normalised to MM/DD/YYYY for DAM
- a2_to_a3_upload_polling.py: lookup before building the asset rep,
  pass override_fields into build_mvp_asset_representation, mark
  applied only after confirmed upload success

Override priority: user edit > master metadata > forced defaults >
hardcoded today+365 validity — so the team's per-asset validity
period (e.g. 1 month) now flows through end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nickviljoen 2026-05-19 12:06:06 +02:00
parent 4e9fb6d18f
commit 9e92db185a
3 changed files with 209 additions and 3 deletions

View file

@ -254,7 +254,19 @@ def process_box_file(file_info, dam, box, db, parser, mvp_extractor, config, not
# 5. Get clean filename
clean_filename = parser.strip_upload_components(filename)
# 6. Build MVP asset representation with CreativeX data from database
# 6. Look up pre-upload metadata override saved by the naming tool's editor.
# The naming tool stores filename without extension, so strip it here.
filename_no_ext = os.path.splitext(filename)[0]
override = db.get_override_metadata(filename_no_ext)
override_fields = None
if override:
override_fields = override.get('override_fields')
logger.info("Found pre-upload override (id={}) for {}: {} field(s)".format(
override.get('id'), filename_no_ext,
len(override_fields) if override_fields else 0
))
# 7. Build MVP asset representation with CreativeX data from database
asset_rep = mvp_extractor.build_mvp_asset_representation(
master_metadata=master_asset['full_metadata'],
clean_filename=clean_filename,
@ -262,7 +274,8 @@ def process_box_file(file_info, dam, box, db, parser, mvp_extractor, config, not
box_metadata=box_metadata, # Pass CreativeX data from database
tracking_mode=tracking_mode, # Pass tracking mode for folder-only handling
master_opentext_id=master_asset['opentext_id'], # Primary master DAM ID
master_opentext_ids=master_opentext_ids # All master IDs (multiple or single)
master_opentext_ids=master_opentext_ids, # All master IDs (multiple or single)
override_fields=override_fields # Pre-upload edits from naming tool
)
# DRYRUN MODE: Display full asset representation and exit
@ -355,6 +368,10 @@ def process_box_file(file_info, dam, box, db, parser, mvp_extractor, config, not
filename=clean_filename
)
# Mark pre-upload override as applied (only after confirmed DAM upload success).
if override:
db.mark_override_applied(filename_no_ext)
# 9. Delete file from Box after successful upload (unless --keep-files flag set)
if keep_files:
logger.info("--keep-files flag set - File kept in Box: {}".format(filename))

View file

@ -1104,6 +1104,88 @@ class Database:
cursor.close()
self.put_connection(conn)
def get_override_metadata(self, filename_without_ext):
"""
Look up pre-upload metadata override saved by the naming tool.
Returns the latest unapplied override row for this filename, or None.
If the override_metadata table doesn't exist (e.g., on a dev DB where the
naming tool migration hasn't been run), returns None — upload behaviour
falls back to today's defaults.
"""
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
SELECT id, tracking_id, override_fields
FROM override_metadata
WHERE filename = %s
AND applied_to_upload = FALSE
ORDER BY created_at DESC
LIMIT 1
""", (filename_without_ext,))
row = cursor.fetchone()
if not row:
return None
override_fields = row[2] if isinstance(row[2], dict) else json.loads(row[2])
return {
'id': row[0],
'tracking_id': row[1],
'override_fields': override_fields,
}
except psycopg2.errors.UndefinedTable:
conn.rollback()
logger.warning("override_metadata table does not exist - skipping override lookup")
return None
except Exception as e:
conn.rollback()
logger.error("Failed to query override_metadata for '{}': {}".format(
filename_without_ext, str(e)
))
return None
finally:
cursor.close()
self.put_connection(conn)
def mark_override_applied(self, filename_without_ext):
"""
Mark a pre-upload override row as applied after a successful DAM upload.
Only updates rows that are currently applied_to_upload = FALSE.
"""
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
UPDATE override_metadata
SET applied_to_upload = TRUE,
applied_at = CURRENT_TIMESTAMP
WHERE filename = %s
AND applied_to_upload = FALSE
""", (filename_without_ext,))
updated = cursor.rowcount
conn.commit()
if updated:
logger.info("Marked {} override row(s) as applied for '{}'".format(
updated, filename_without_ext
))
return updated
except psycopg2.errors.UndefinedTable:
conn.rollback()
return 0
except Exception as e:
conn.rollback()
logger.error("Failed to mark override applied for '{}': {}".format(
filename_without_ext, str(e)
))
return 0
finally:
cursor.close()
self.put_connection(conn)
def close(self):
"""Close all connections in pool"""
if self.pool:

View file

@ -13,6 +13,36 @@ from shared.config_loader import load_country_code_mappings
logger = logging.getLogger('MetadataExtractorMVP')
# Editor field name -> DAM metadata field ID.
# Mirrors the canonical mapping in the naming tool's public-v2/Database.php
# so that pre-upload overrides saved via the metadata editor are applied to
# the matching DAM fields on upload.
OVERRIDE_FIELD_MAP = {
'validity_start': 'FERRERO.FIELD.ASSET VALIDITY START PERIOD',
'validity_end': 'FERRERO.FIELD.ASSET VALIDITY END PERIOD',
'marketing_tag': 'MARKETING_TAG',
'agency_name': 'FERRERO.MARKETING.FIELD.AGENCY NAME',
'spot_version': 'FERRERO.MARKETING.FIELD.SPOT_VERSION',
'director_name': 'FERRERO.MARKETING.FIELD.DIRECTOR_NAME',
'video_post_prod_company': 'FERRERO.MARKETING.FIELD.VIDEO_POST_PROD_COMPANY',
'video_post_prod_contact': 'FERRERO.MARKETING.FIELD.VID_POST_PROD_CONTACT',
'audio_post_prod_company': 'FERRERO.MARKETING.FIELD.AUDIO_POST_PROD_COMPANY',
'audio_post_prod_contact': 'FERRERO.MARKETING.FIELD.AUDIO_POST_PROD_CONTACT',
'video_type': 'FERRERO.MARKET.FIELD.TYPE_VID',
'ip_rights': 'FERRERO.MARKET.FIELD.IPRIGHT',
'production_company': 'FERRERO.MARKET.PROD_COMPANY',
'licensing': 'FERRERO.MARKET.FIELD.LICENSIN',
'buyout': 'FERRERO.MARKET.FIELD.BUYOUT',
'ferrero_property': 'FERRERO.MARKET.FIELD.FERRERO PROPERTY',
'video_status': 'FERRERO.MARKET.VID_N_STAT',
'license': 'FERRERO.MARKET.FIELD.LICENSE',
'creativex_score': 'FERRERO.TAB.FIELD.CREATIVEX',
'creativex_link': 'FERRERO.FIELD.CREATIVEX LINK',
}
DATE_OVERRIDE_FIELDS = {'validity_start', 'validity_end'}
class MetadataExtractorMVP:
def __init__(self, field_mappings):
"""
@ -113,7 +143,7 @@ class MetadataExtractorMVP:
return extracted_fields
def build_mvp_asset_representation(self, master_metadata, clean_filename, parsed_filename, box_metadata=None, tracking_mode='full', master_opentext_id=None, master_opentext_ids=None):
def build_mvp_asset_representation(self, master_metadata, clean_filename, parsed_filename, box_metadata=None, tracking_mode='full', master_opentext_id=None, master_opentext_ids=None, override_fields=None):
"""
Build asset representation with MVP fields + updates from filename
@ -124,6 +154,10 @@ class MetadataExtractorMVP:
box_metadata: Optional Box metadata
tracking_mode: 'full' (inherit all metadata) or 'folder_only' (only use folder)
master_opentext_id: Optional DAM Asset ID of master asset (for derivative tracking)
override_fields: Optional dict of pre-upload metadata overrides keyed by
editor field name (e.g. {'validity_end': '...', 'ip_rights': 'Yes'}).
Applied after master/filename/forced values but before asset-type
overrides so EOL/LTD compliance still wins. Empty values are skipped.
Returns:
Asset representation dict ready for upload
@ -160,6 +194,13 @@ class MetadataExtractorMVP:
if box_metadata:
mvp_fields = self._update_creativex_fields(mvp_fields, box_metadata)
# Apply pre-upload metadata overrides from the naming tool's editor.
# Runs after master/filename/forced/default/CreativeX values so it wins
# over them, but before asset_type_overrides so EOL/LTD compliance rules
# still take final precedence.
if override_fields:
mvp_fields = self._apply_override_fields(mvp_fields, override_fields)
# Apply asset type overrides (e.g., EOL, LTD) - takes final precedence over
# forced values, defaults, and CreativeX (LTD removes CreativeX entirely).
mvp_fields = self._apply_asset_type_overrides(mvp_fields, parsed_filename)
@ -921,6 +962,72 @@ class MetadataExtractorMVP:
return field['value']['value']['field_value'].get('value')
return None
def _apply_override_fields(self, mvp_fields, override_fields):
"""
Apply pre-upload metadata overrides from the naming tool.
For each non-empty entry in override_fields, map the editor field name
to its DAM field ID via OVERRIDE_FIELD_MAP and write the value into the
matching field in mvp_fields. Empty strings are skipped (treat as
"user didn't set this, leave inherited value alone"). Validity dates
from the editor arrive as ISO 8601 strings and are normalised to the
MM/DD/YYYY format DAM expects.
"""
if not override_fields:
return mvp_fields
applied = 0
for editor_field, raw_value in override_fields.items():
if raw_value is None or raw_value == '':
continue
dam_field_id = OVERRIDE_FIELD_MAP.get(editor_field)
if not dam_field_id:
logger.debug("Override: no DAM mapping for editor field '{}' - skipping".format(editor_field))
continue
value = raw_value
if editor_field in DATE_OVERRIDE_FIELDS:
value = self._normalize_iso_date(raw_value)
if not value:
continue
target = None
for field in mvp_fields:
if field.get('id') == dam_field_id:
target = field
break
if target is None:
logger.warning("Override: field {} (DAM id {}) not present in mvp_fields - skipping".format(
editor_field, dam_field_id
))
continue
if editor_field in DATE_OVERRIDE_FIELDS:
self._set_date_field_value(target, value)
else:
self._set_field_value(target, value)
logger.info("Override applied: {} ({}) = {}".format(editor_field, dam_field_id, value))
applied += 1
if applied:
logger.info("Applied {} pre-upload override field(s) from naming tool".format(applied))
return mvp_fields
def _normalize_iso_date(self, iso_str):
"""Convert an ISO 8601 date string (with or without time/timezone) to MM/DD/YYYY."""
if not iso_str:
return None
try:
date_part = iso_str.split('T')[0]
dt = datetime.strptime(date_part, '%Y-%m-%d')
return dt.strftime('%m/%d/%Y')
except Exception as e:
logger.warning("Could not normalize override date '{}': {}".format(iso_str, str(e)))
return None
def _set_field_value(self, field, value):
"""Set field value handling different structures"""
import json