#!/usr/bin/env python3 """ Fase 5 / Etapa 2 / sub-fase 1 - cierre de fix #2. Asigna `collection_id` real a los productos legacy que el mapeo de Toy Story / Intensamente / Lilo & Stitch / Marvel / Frozen requiere, y asigna la categoria "Figuras de accion" al producto cybertron-sentinel-figura-de-coleccion-test-revalidate (unico de los 18 productos pre-existentes sin categoria real). NO toca product.metadata (brand/ageRange), precios, descripciones ni imagenes. Solo categories/collection_id via Admin API. Uso: ADMIN_JWT= python3 migrate_legacy_collections.py --dry-run ADMIN_JWT= python3 migrate_legacy_collections.py --apply El JWT se obtiene con POST /auth/user/emailpass (usuario de prueba claude-test@cruzcloud.net de esta sesion) y no se persiste en este archivo ni en disco. Endpoints Medusa v2 confirmados en la sesion previa (ver apps-registry docs/memoria del lab, PR #27/#28): - POST /admin/collections (crear coleccion) - GET /admin/collections?title=X (chequear existencia) - POST /admin/collections/:id/products {add, remove} (asignar productos) - POST /admin/product-categories/:id/products {add, remove} (asignar categoria) Los intentos con category_ids/collection_id en el body del producto o con /admin/products/:id/categories NO existen en esta version (404/400). """ import argparse import json import os import sys import urllib.request import urllib.error import urllib.parse BACKEND_URL = os.environ.get("MEDUSA_BACKEND_URL", "https://commerce.cruzcloud.net") ADMIN_JWT = os.environ.get("ADMIN_JWT") # Coleccion -> lista de (handle, product_id) COLLECTIONS = { "Toy Story": [ ("toy-story-5-rex", "prod_01KZW8GK52V50RWNSDQ6CARC16"), ("toy-story-5-buzz-lightyear", "prod_01KZW8GMEJRG8GPZFYCY2PYA73"), ("toy-story-5-jessie", "prod_01KZW8GP4XNJHZRJJ8GJXCPQT7"), ("toy-story-5-woody", "prod_01KZW8GPVFYWE2BM3C8501HKGQ"), ], "Intensamente": [ ("intensamente-furia", "prod_01KZW8GQPTXJNDQVKKMQEPERN7"), ("intensamente-tristeza", "prod_01KZW8GRA76WYW7RYBRBF64V9B"), ], "Lilo & Stitch": [ ("angel-lilo-y-stitch", "prod_01KZW8GSGXR55AC1W5G3Y1AZY9"), ], } # Colecciones que YA existen (creadas en Etapa 1) -> (id, lista de (handle, product_id)) EXISTING_COLLECTIONS = { "Universo Marvel": ( "pcol_01M0PDFBDA0TXQNCX4E2AP8DJ5", [ ("mochilas-disney-coleccion-marvel", "prod_01KZW8GT45M3B03GH161Z5C87G"), ("mochila-escolar-de-spider-man-de-disney-moda-estudiantil-libro-de-gran-capacidad-mochila-ligera", "prod_01KZW8GVEJGPC6AWYWAJX25TAP"), ("disney-mochila-iron-man-de-poliester-ultraligera-y-transpirable-de-gran-tamano-dimensiones-46-cm-x", "prod_01KZW8GZ2KM9E6ZNTSRHYZETPB"), ("mochila-de-disney-marvel-capitan-america-de-gran-capacidad-ligera-y-resistente-a-salpicaduras-par", "prod_01KZW8H03649RWT2EMG3B0QSG5"), ], ), "Reino de Frozen": ( "pcol_01M0PDFBX1PT3ZA4KHJE6C9VKT", [ ("mochila-ligera-y-resistente-al-agua-de-disney-frozen-mochila-escolar-y-de-viaje-con-multiples-compa", "prod_01KZW8GTSV6DZ1PHQFVKXFKXTR"), ], ), } # Categoria "Figuras de accion" (Juguetes > Figuras de accion), ya existe desde Etapa 1 FIGURAS_ACCION_CATEGORY_ID = "pcat_01M0PDF24BRVRKKMPMXCTY7QXJ" CATEGORY_ASSIGNMENTS = [ ("cybertron-sentinel-figura-de-coleccion-test-revalidate", "prod_01KZYM5RZT89DRD3A86W3DQ164"), ] def request(method, path, body=None): url = f"{BACKEND_URL}{path}" data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(url, data=data, method=method) req.add_header("Authorization", f"Bearer {ADMIN_JWT}") req.add_header("Content-Type", "application/json") try: with urllib.request.urlopen(req) as resp: return resp.status, json.loads(resp.read().decode()) except urllib.error.HTTPError as e: return e.code, {"error": e.read().decode()} def find_collection_id(title): status, body = request("GET", f"/admin/collections?title={urllib.parse.quote(title)}") if status != 200: raise RuntimeError(f"GET /admin/collections?title={title} -> {status} {body}") cols = body.get("collections", []) return cols[0]["id"] if cols else None def main(): parser = argparse.ArgumentParser() group = parser.add_mutually_exclusive_group(required=True) group.add_argument("--dry-run", action="store_true") group.add_argument("--apply", action="store_true") args = parser.parse_args() if not ADMIN_JWT: sys.exit("ADMIN_JWT env var requerido") plan = [] # 1. Colecciones nuevas: crear si no existen, luego asignar productos for title, products in COLLECTIONS.items(): existing_id = find_collection_id(title) if existing_id: plan.append(("skip-create-collection", title, existing_id)) collection_id = existing_id else: plan.append(("create-collection", title, None)) collection_id = None if args.apply: if collection_id is None: status, body = request("POST", "/admin/collections", { "title": title, "handle": title.lower().replace(" & ", "-").replace(" ", "-"), }) if status not in (200, 201): raise RuntimeError(f"create collection {title} -> {status} {body}") collection_id = body["collection"]["id"] print(f"[apply] creada coleccion '{title}' -> {collection_id}") product_ids = [pid for _, pid in products] status, body = request( "POST", f"/admin/collections/{collection_id}/products", {"add": product_ids, "remove": []}, ) if status not in (200, 201): raise RuntimeError(f"assign products to {title} -> {status} {body}") print(f"[apply] asignados {len(product_ids)} productos a '{title}'") else: for handle, pid in products: plan.append(("assign-collection", title, handle, pid)) # 2. Colecciones existentes (Universo Marvel, Reino de Frozen): solo asignar for title, (collection_id, products) in EXISTING_COLLECTIONS.items(): if args.apply: product_ids = [pid for _, pid in products] status, body = request( "POST", f"/admin/collections/{collection_id}/products", {"add": product_ids, "remove": []}, ) if status not in (200, 201): raise RuntimeError(f"assign products to {title} -> {status} {body}") print(f"[apply] asignados {len(product_ids)} productos a '{title}' (ya existia)") else: for handle, pid in products: plan.append(("assign-collection", title, handle, pid)) # 3. Categoria faltante de cybertron-sentinel if args.apply: product_ids = [pid for _, pid in CATEGORY_ASSIGNMENTS] status, body = request( "POST", f"/admin/product-categories/{FIGURAS_ACCION_CATEGORY_ID}/products", {"add": product_ids, "remove": []}, ) if status not in (200, 201): raise RuntimeError(f"assign category to cybertron-sentinel -> {status} {body}") print(f"[apply] asignada categoria 'Figuras de accion' a cybertron-sentinel") else: for handle, pid in CATEGORY_ASSIGNMENTS: plan.append(("assign-category", "Figuras de accion", handle, pid)) if not args.apply: print(json.dumps(plan, indent=2, ensure_ascii=False)) print(f"\n[dry-run] {len(plan)} acciones planeadas, ninguna ejecutada.") if __name__ == "__main__": main()