21b6a30f08
Move a template fully between environments (local → live): container, projects, all scenes + editable fields, shared colours/layers, its categories & tags, and the asset files. - export_template.py <slug> → a self-contained bundle (template.json + assets/). One SQL query captures the whole tree as JSON; assets are resolved from template-media references and copied in. Source DB via PSQL env (default = local docker). - import_template.py <bundle> → idempotent SQL (pipe to target psql). Replaces by slug via one cascading delete (all content.* FKs are ON DELETE CASCADE), recreates rows verbatim (UUIDs preserved → FKs intact), merges categories/tags BY SLUG so they line up across DBs. --assets-to copies media; docker cp / mc cp hints for remote. - TEMPLATE_BUNDLES.md documents it. Round-trip tested on fr-instagram-promo: DB → bundle → DB restores identical 3 projects / 15 scenes / 138 fields and field values. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
87 lines
4.5 KiB
Python
87 lines
4.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Import a template bundle (from export_template.py) into ANY FlatRender DB.
|
|
|
|
Emits idempotent SQL to STDOUT — pipe it to the target's psql. It replaces any
|
|
existing template with the same slug (one cascading delete), recreates the rows
|
|
verbatim (original UUIDs, so FKs stay intact), and merges categories & tags BY SLUG
|
|
(so they line up with whatever the target DB already calls them).
|
|
|
|
Usage:
|
|
python scripts/import_template.py <bundle_dir> | <target-psql>
|
|
# e.g. local:
|
|
python scripts/import_template.py dist/template-bundles/fr-instagram-promo \\
|
|
| docker exec -i fr2-postgres psql -U postgres -d flatrender
|
|
# e.g. live:
|
|
python scripts/import_template.py dist/template-bundles/fr-instagram-promo \\
|
|
| psql "postgresql://user:pass@live-host:5432/flatrender"
|
|
|
|
Assets: copy the bundle's assets/ into the target's template-media (the SQL only
|
|
moves DB rows). Pass --assets-to <dir> to copy them locally; for a remote box use
|
|
`docker cp` / `mc cp` (printed below).
|
|
"""
|
|
import os, sys, json, shutil
|
|
|
|
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
|
if not args:
|
|
print("usage: import_template.py <bundle_dir> [--assets-to <dir>] | <target-psql>", file=sys.stderr); sys.exit(1)
|
|
BUNDLE = args[0]
|
|
ASSETS_TO = None
|
|
if "--assets-to" in sys.argv:
|
|
ASSETS_TO = sys.argv[sys.argv.index("--assets-to") + 1]
|
|
|
|
with open(os.path.join(BUNDLE, "template.json"), encoding="utf-8") as f:
|
|
b = json.load(f)
|
|
SLUG = b["slug"]
|
|
|
|
def qv(v):
|
|
if v is None: return "NULL"
|
|
if isinstance(v, bool): return "TRUE" if v else "FALSE"
|
|
if isinstance(v, (int, float)): return str(v)
|
|
if isinstance(v, (dict, list)): return "'" + json.dumps(v, ensure_ascii=False).replace("'", "''") + "'"
|
|
return "'" + str(v).replace("'", "''") + "'"
|
|
|
|
def ins(table, row, conflict=None):
|
|
cols = list(row.keys())
|
|
s = f"INSERT INTO content.{table} ({','.join(cols)}) VALUES ({','.join(qv(row[c]) for c in cols)})"
|
|
if conflict: s += f" ON CONFLICT ({conflict}) DO NOTHING"
|
|
return s + ";"
|
|
|
|
out = ["BEGIN;"]
|
|
# categories & tags first (merge by slug — never clobber the target's own)
|
|
for cat in b.get("categories") or []: out.append(ins("categories", cat, "slug"))
|
|
for tg in b.get("tags") or []: out.append(ins("tags", tg, "slug"))
|
|
# replace any existing template with this slug (cascades to all children)
|
|
out.append(f"DELETE FROM content.project_containers WHERE slug={qv(SLUG)};")
|
|
# rows verbatim (original UUIDs keep the FK tree intact)
|
|
out.append(ins("project_containers", b["container"]))
|
|
for p in b.get("projects") or []: out.append(ins("projects", p))
|
|
for s in b.get("scenes") or []: out.append(ins("scenes", s))
|
|
for e in b.get("content_elements") or []: out.append(ins("scene_content_elements", e))
|
|
for sc in b.get("shared_colors") or []: out.append(ins("shared_colors", sc))
|
|
for sl in b.get("shared_layers") or []: out.append(ins("shared_layers", sl))
|
|
# links resolve the category/tag id BY SLUG in the target DB
|
|
cid = b["container"]["id"]
|
|
for link in b.get("category_links") or []:
|
|
out.append(f"INSERT INTO content.container_categories (container_id,category_id,sort) "
|
|
f"SELECT {qv(cid)}, id, {qv(link.get('sort', 0))} FROM content.categories WHERE slug={qv(link['category_slug'])} ON CONFLICT DO NOTHING;")
|
|
for link in b.get("tag_links") or []:
|
|
out.append(f"INSERT INTO content.container_tags (container_id,tag_id) "
|
|
f"SELECT {qv(cid)}, id FROM content.tags WHERE slug={qv(link['tag_slug'])} ON CONFLICT DO NOTHING;")
|
|
out.append("COMMIT;")
|
|
out.append(f"SELECT slug, name, primary_mode FROM content.project_containers WHERE slug={qv(SLUG)};")
|
|
print("\n".join(out))
|
|
|
|
# Assets: copy locally if asked; otherwise print placement guidance to stderr.
|
|
assets = b.get("assets") or []
|
|
if ASSETS_TO and assets:
|
|
os.makedirs(ASSETS_TO, exist_ok=True)
|
|
for a in assets:
|
|
src = os.path.join(BUNDLE, "assets", a)
|
|
if os.path.isfile(src): shutil.copy2(src, os.path.join(ASSETS_TO, a))
|
|
sys.stderr.write(f"-- copied {len(assets)} asset(s) → {ASSETS_TO}\n")
|
|
elif assets:
|
|
sys.stderr.write(f"\n-- {len(assets)} asset(s) in {BUNDLE}/assets/ — place them in the target's template-media, e.g.:\n")
|
|
sys.stderr.write(f"-- local docker: for f in {BUNDLE}/assets/*; do docker cp \"$f\" fr2-frontend:/app/public/template-media/; done\n")
|
|
sys.stderr.write(f"-- live (MinIO): mc cp --recursive {BUNDLE}/assets/ myminio/<bucket>/template-media/\n")
|
|
sys.stderr.write(f"-- or just copy {BUNDLE}/assets/* into the live app's public/template-media/\n")
|