Files
scripts/migrate_legacy_collections.py
devops 781bc459dd Add script to migrate legacy collections + cybertron-sentinel category
Documents and makes repeatable the Admin API writes for Fase 5 Etapa 2
sub-fase 1: assign real Medusa collections (Toy Story, Intensamente,
Lilo & Stitch, Universo Marvel, Reino de Frozen) to 9 legacy products,
and the missing "Figuras de accion" category to cybertron-sentinel.
Categories for the 17 legacy products were already correct and are
untouched.
2026-08-24 15:45:14 -05:00

187 lines
7.7 KiB
Python

#!/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=<token> python3 migrate_legacy_collections.py --dry-run
ADMIN_JWT=<token> python3 migrate_legacy_collections.py --apply
El JWT se obtiene con POST /auth/user/emailpass (usuario de prueba
[email protected] 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()