Recipe: bulk-import performers from a spreadsheet (idempotent upsert)
Recipe: bulk-import performers from a spreadsheet (idempotent upsert)
Recipe: bulk-import performers from a spreadsheet (idempotent upsert)
Prompt exampleImport every row of "Ideas 2026 Speakers to Nomad.xlsx" into the performer table: create a performer per row with the correct slug format and PerformerType set to "Speaker", and make a re-run update the same records instead of creating duplicates.
Reference entities like Performers follow a simpler import contract than Events - no
datetime conversion, no live schedule - but the idempotency, slug format, and
reference resolution patterns are exactly the same. This recipe covers the complete
flow: discover the entity's fields and existing slug conventions, resolve
the PerformerType lookup once, dry-run one row to validate the payload, then
idempotently upsert every row keyed on slug.
Safety (Class C). Performers are global account objects with no folder anchor.
Tear down anything you create withdelete_content(sdk, id, PERFORMER_CD).
Production imports must be a human-reviewed, explicitly-approved action.
Before you write: discover first
Run these steps before building any payload. They take ~2 minutes and prevent silent
mismatches that are hard to diagnose after a 400-row import.
1. Confirm the content definition fields
cd = sdk.get_content_definition(PERFORMER_CD)
print([f["properties"]["propertyName"] for f in cd["contentFields"]])
# e.g. ['Name', 'Slug', 'PerformerType', 'Bio', ...]This confirms the exact propertyName values to use as write keys (camelCase in the
payload - first-letter-lower the CD names: Name → name, PerformerType → performerType).
2. Inspect existing slug format
Search a sample of existing performers and read their identifiers.slug:
flt = [{"fieldName": "contentDefinitionId", "operator": "Equals", "values": PERFORMER_CD}]
sample = sdk.search(None, 0, 5, flt, None, None, None, None, None, None, None, None, None, None, None)
for item in sample["items"]:
print(item["identifiers"].get("slug"))
# e.g. landrieu-mitch, burns-ken, jackson-gregNomad stores performer slugs as lastname-firstname (all lowercase, hyphen-separated).
Spreadsheets frequently use Firstname-Lastname (title-case). Transform before importing -
see slug helper below.
3. Enumerate PerformerType lookup values
Search the PerformerType CD with only the CD filter to get all values and their IDs:
flt = [{"fieldName": "contentDefinitionId", "operator": "Equals", "values": PERFORMER_TYPE_CD}]
res = sdk.search(None, 0, 50, flt, None, None, None, None, None, None, None, None, None, None, None)
for item in res["items"]:
ids = item.get("identifiers", {})
print(ids.get("name"), "->", item["id"])
# Speaker -> 033ed0e6-4189-4dcd-91e3-89efbcf64ec9
# Actor -> ...Build a name → {id, description} map once and reuse it for every row.
Admin credentials
Credentials live in AWS Secrets Manager under nomad/<prefix>/adminUserCredentials.
Fetch them with:
aws secretsmanager get-secret-value \
--secret-id nomad/<prefix>/adminUserCredentials \
--region <region>
Returns {"username": "[email protected]", "password": "..."}. Use these to
initialize the SDK - never hardcode them in the script.
Slug transformation
Spreadsheet slugs are typically Firstname-Lastname (title-case). The platform stores
lastname-firstname (lowercase). Transform each row before writing:
import re
def name_to_slug(name: str) -> str:
# Strip suffixes like ", Jr." or " Sr."
name = re.sub(r",?\s+(jr\.?|sr\.?|ii|iii|iv|v)\.?\s*$", "", name, flags=re.IGNORECASE).strip()
parts = name.split()
if len(parts) == 1:
return parts[0].lower()
last, first_parts = parts[-1], parts[:-1]
slug = last + "-" + "-".join(first_parts)
# lowercase, strip periods and apostrophes, normalize hyphens
return slug.lower().replace(".", "").replace("'", "").replace("‑", "-")Run this transformation and deduplicate by slug before importing. If two rows produce
the same slug after transformation, fail-closed before any write.
The writes per row
Each row is the standard two-step content contract:
create_content(PERFORMER_CD, None)- only when the slug is new. Returns
{"contentId": "..."}. Keep thecontentId-update_contentreturnsNone
even on success.update_content(content_id, PERFORMER_CD, props, None)- one properties-only
patch:name,slug,performerType(required ref). All four arguments are
positional - passNoneforlanguage_id. Keyword arguments raise
missing required positional argument.
Casing note. The CD reports
propertyNamein PascalCase (Name,Slug,
PerformerType), but write keys are camelCase (name,slug,performerType).
Write camelCase; first-letter-lower the CD names before comparing.
Mapping plan
| Spreadsheet column | Destination (write) | How it is resolved |
|---|---|---|
Name | performer.name | verbatim |
Slug | performer.slug - idempotency key | transformed to lastname-firstname lowercase |
Role / (constant) | performer.performerType (required ref) | name → {id, description} via PerformerType CD, resolved once before the loop |
Python
# Components: get_content_definition, search, create_content, update_content, delete_content
import re
import sys
import pandas as pd
sys.path.insert(0, r"C:\Projects\GitHub\test-suite")
from nomad_media_pip.src.nomad_sdk import Nomad_SDK
PERFORMER_CD = "33cec5ca-6170-4237-842b-78bf1ef17932"
PERFORMER_TYPE_CD = "3d4c1ffa-d7be-4ad8-ac91-7aaf03a5c6f1"
def name_to_slug(name):
name = name.replace("‑", "-") # normalize non-breaking hyphens
name = re.sub(r",?\s+(jr\.?|sr\.?|ii|iii|iv|v)\.?\s*$", "", name, flags=re.IGNORECASE).strip()
parts = name.split()
if len(parts) == 1:
return parts[0].lower()
return (parts[-1] + "-" + "-".join(parts[:-1])).lower().replace(".", "").replace("'", "")
def assert_unique_slugs(rows):
seen, dupes = set(), set()
for r in rows:
slug = r["slug"]
(dupes if slug in seen else seen).add(slug)
if dupes:
raise ValueError(f"Duplicate slugs in spreadsheet (aborting): {sorted(dupes)}")
def resolve_performer_type(sdk, name):
"""Resolve a PerformerType name to {id, description}; raise if not found."""
flt = [
{"fieldName": "contentDefinitionId", "operator": "Equals", "values": PERFORMER_TYPE_CD},
{"fieldName": "name", "operator": "Equals", "values": name},
]
res = sdk.search(None, 0, 1, flt, None, None, None, None, None, None, None, None, None, None, None)
items = (res or {}).get("items", [])
if not items:
raise ValueError(f"PerformerType '{name}' not found on this deployment")
return {"id": items[0]["id"], "description": name}
def find_by_slug(sdk, slug):
flt = [
{"fieldName": "contentDefinitionId", "operator": "Equals", "values": PERFORMER_CD},
{"fieldName": "slug", "operator": "Equals", "values": slug},
]
items = (sdk.search(None, 0, 1, flt, None, None, None, None, None, None, None, None, None, None, None) or {}).get("items", [])
return items[0]["id"] if items else None
def upsert_performer(sdk, row, performer_type_ref, dry_run=False):
props = {"name": row["name"], "slug": row["slug"], "performerType": performer_type_ref}
if dry_run:
existing = find_by_slug(sdk, row["slug"])
print(f" DRY-RUN [{'would-update' if existing else 'would-create'}] {row['name']} | {row['slug']}")
return existing, "dry-run"
existing_id = find_by_slug(sdk, row["slug"])
if existing_id:
sdk.update_content(existing_id, PERFORMER_CD, props, None)
return existing_id, "updated"
result = sdk.create_content(PERFORMER_CD, None)
content_id = result["contentId"]
sdk.update_content(content_id, PERFORMER_CD, props, None)
return content_id, "created"
def run_import(sdk, spreadsheet_path, performer_type_name="Speaker", dry_run=False):
df = pd.read_excel(spreadsheet_path)
rows = [{"name": str(r["Name"]).strip(), "slug": name_to_slug(str(r["Name"]).strip())}
for _, r in df.iterrows() if str(r["Name"]).strip()]
assert_unique_slugs(rows)
# Discover field names and confirm CD is reachable
cd = sdk.get_content_definition(PERFORMER_CD)
print("Performer fields:", [f["properties"]["propertyName"] for f in cd["contentFields"]])
# Resolve PerformerType once
performer_type_ref = resolve_performer_type(sdk, performer_type_name)
print(f"PerformerType '{performer_type_name}': {performer_type_ref['id']}")
# Dry-run first 3 rows
print(f"\nDry-run preview (first 3 rows):")
for row in rows[:3]:
upsert_performer(sdk, row, performer_type_ref, dry_run=True)
if dry_run:
print(f"\n ... and {len(rows) - 3} more rows would be processed.")
return
created, updated, errors = 0, 0, []
for i, row in enumerate(rows):
try:
_, action = upsert_performer(sdk, row, performer_type_ref)
if action == "created":
created += 1
else:
updated += 1
if (i + 1) % 50 == 0:
print(f" Progress: {i+1}/{len(rows)} (created={created}, updated={updated})")
except Exception as e:
errors.append({"name": row["name"], "slug": row["slug"], "error": str(e)})
print(f" ERROR: {row['name']} ({row['slug']}): {e}")
print(f"\nImport complete: {created} created, {updated} updated, {len(errors)} errors.")
if errors:
for err in errors:
print(f" FAILED: {err['name']} ({err['slug']}): {err['error']}")
# Sample verify
print("\nVerifying sample (first 10 + last 10)...")
ok = 0
for row in rows[:10] + rows[-10:]:
flt = [
{"fieldName": "contentDefinitionId", "operator": "Equals", "values": PERFORMER_CD},
{"fieldName": "slug", "operator": "Equals", "values": row["slug"]},
]
items = (sdk.search(None, 0, 1, flt, None, None, None, None, None, None, None, None, None, None, None) or {}).get("items", [])
if items and items[0].get("identifiers", {}).get("name") == row["name"]:
ok += 1
else:
print(f" DIFF/MISSING: {row['name']} ({row['slug']})")
print(f"Sample verify: {ok}/20 OK.")JavaScript
Same flow; parse the workbook with exceljs or xlsx, then resolve/upsert exactly
as in Python. The reference shapes and the write sequence are identical.
// Components: getContentDefinition, search, createContent, updateContent, deleteContent
const PERFORMER_CD = "33cec5ca-6170-4237-842b-78bf1ef17932";
const PERFORMER_TYPE_CD = "3d4c1ffa-d7be-4ad8-ac91-7aaf03a5c6f1";
function nameToSlug(name) {
name = name.replace(/‑/g, "-")
.replace(/,?\s+(jr\.?|sr\.?|ii|iii|iv|v)\.?\s*$/i, "").trim();
const parts = name.split(/\s+/);
if (parts.length === 1) return parts[0].toLowerCase();
const last = parts[parts.length - 1];
const first = parts.slice(0, -1);
return (last + "-" + first.join("-")).toLowerCase().replace(/\./g, "").replace(/'/g, "");
}
function assertUniqueSlugs(rows) {
const seen = new Set(), dupes = new Set();
for (const r of rows) {
if (seen.has(r.slug)) dupes.add(r.slug); else seen.add(r.slug);
}
if (dupes.size) throw new Error(`Duplicate slugs (aborting): ${[...dupes].join(", ")}`);
}
async function resolvePerformerType(sdk, name) {
const flt = [
{ fieldName: "contentDefinitionId", operator: "Equals", values: PERFORMER_TYPE_CD },
{ fieldName: "name", operator: "Equals", values: name },
];
const items = ((await sdk.search(null, 0, 1, flt, null, null, null, null, null, null, null, null, null, null, null)) || {}).items || [];
if (!items.length) throw new Error(`PerformerType '${name}' not found on this deployment`);
return { id: items[0].id, description: name };
}
async function findBySlug(sdk, slug) {
const flt = [
{ fieldName: "contentDefinitionId", operator: "Equals", values: PERFORMER_CD },
{ fieldName: "slug", operator: "Equals", values: slug },
];
const items = ((await sdk.search(null, 0, 1, flt, null, null, null, null, null, null, null, null, null, null, null)) || {}).items || [];
return items.length ? items[0].id : null;
}
async function upsertPerformer(sdk, row, performerTypeRef) {
const props = { name: row.name, slug: row.slug, performerType: performerTypeRef };
const existingId = await findBySlug(sdk, row.slug);
if (existingId) {
await sdk.updateContent(existingId, PERFORMER_CD, props, null);
return { id: existingId, action: "updated" };
}
const { contentId } = await sdk.createContent(PERFORMER_CD, null);
await sdk.updateContent(contentId, PERFORMER_CD, props, null);
return { id: contentId, action: "created" };
}Notes
- Discover before writing.
get_content_definition(PERFORMER_CD)confirms field names.
Inspect a sample of existing slugs before transforming spreadsheet slugs - the convention
may differ by customer. - Admin credentials come from Secrets Manager. The secret is typically
nomad/<prefix>/adminUserCredentials. Never hardcode credentials in the script. create_contentandupdate_contentare fully positional. Both require
language_idas the last argument - passNonefor the default language. Keyword
arguments raisemissing required positional argument.performerTypeis required. Omit it and the write silently produces an incomplete
record. Resolve the PerformerType lookup table once before the loop - it's a small,
stable table (typically < 20 rows).- Idempotency is the slug.
find_by_slugchecks for an existing record before
create_content, so re-running the import updates in place instead of duplicating.
assert_unique_slugsfails-closed if the spreadsheet itself has duplicate slugs after
transformation. update_contentreturnsNoneeven on success. Keep thecontentIdfrom
create_content- do not rely on the return value ofupdate_contentas confirmation.- Tear down what you create (Class C). Performers have no folder anchor, so they
cannot be reclaimed by a run-root cascade. Delete with
delete_content(sdk, content_id, PERFORMER_CD)in non-prod.
Updated about 1 month ago
