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.
This commit is contained in:
1 parent
200a56de05
commit
781bc459dd
2 files changed
+270
No files matched your search
@@ -0,0 +1,84 @@
|
|||||||
|
# Migración de colecciones legacy (Fase 5 / Etapa 2 / sub-fase 1, fix #2)
|
||||||
|
|
||||||
|
Script: `scripts/migrate_legacy_collections.py`.
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
Al cerrar el fix #2 de la sub-fase 1 (categorías reales) se confirmó vía
|
||||||
|
Store API (2026-08-24) que los 17 productos legacy ya tenían la
|
||||||
|
`category` real correcta asignada desde una migración anterior, pero
|
||||||
|
**ningún producto tenía `collection` asignada** — el campo estaba en
|
||||||
|
`null` en los 27 productos existentes en ese momento (17 legacy + 1
|
||||||
|
Cybertron Sentinel + 9 demo). Sólo 2 colecciones existían en Medusa:
|
||||||
|
"Universo Marvel" y "Reino de Frozen" (creadas en la auditoría de
|
||||||
|
Etapa 1). Este script cierra esa brecha.
|
||||||
|
|
||||||
|
De paso asigna la categoría "Figuras de accion" a
|
||||||
|
`cybertron-sentinel-figura-de-coleccion-test-revalidate`, el único de
|
||||||
|
los 18 productos pre-existentes (no-demo) que quedó sin categoría en
|
||||||
|
la migración anterior — no es uno de los "17 legacy", es el producto
|
||||||
|
usado para validar el flujo de auto-refresh en el incidente Fase 4.
|
||||||
|
|
||||||
|
**Fuera de alcance, por decisión explícita del usuario:** `metadata`
|
||||||
|
(brand/ageRange), precios, descripciones, imágenes. Solo
|
||||||
|
`categories`/`collection_id`.
|
||||||
|
|
||||||
|
## Mapeo aplicado
|
||||||
|
|
||||||
|
| Colección | Nueva/existente | Productos (handle) |
|
||||||
|
|---|---|---|
|
||||||
|
| Toy Story | nueva | toy-story-5-rex, toy-story-5-buzz-lightyear, toy-story-5-jessie, toy-story-5-woody |
|
||||||
|
| Intensamente | nueva | intensamente-furia, intensamente-tristeza |
|
||||||
|
| Lilo & Stitch | nueva | angel-lilo-y-stitch |
|
||||||
|
| Universo Marvel | existente (`pcol_01M0PDFBDA0TXQNCX4E2AP8DJ5`) | mochilas-disney-coleccion-marvel, mochila-escolar-de-spider-man-..., disney-mochila-iron-man-..., mochila-de-disney-marvel-capitan-america-... |
|
||||||
|
| Reino de Frozen | existente (`pcol_01M0PDFBX1PT3ZA4KHJE6C9VKT`) | mochila-ligera-...-frozen-... |
|
||||||
|
| — (sin colección, por mapeo explícito del usuario) | — | minnie-y-mickey-mouse, mochila-pequena-bob-esponja, mochila-casual-...-bob-esponja-..., disney-mochila-de-poliester-..., mochila-escolar-estilo-britanico-... |
|
||||||
|
|
||||||
|
Categoría faltante: `cybertron-sentinel-figura-de-coleccion-test-revalidate`
|
||||||
|
→ "Figuras de accion" (`pcat_01M0PDF24BRVRKKMPMXCTY7QXJ`).
|
||||||
|
|
||||||
|
Las 17 categorías legacy en sí **no se tocan** — se verificaron 1:1
|
||||||
|
contra el mapeo del usuario antes de correr este script y ya
|
||||||
|
coincidían exactamente (ver memoria del proyecto
|
||||||
|
`project_fase5_etapa2_subfase1_categorias_2026_08_23.md`).
|
||||||
|
|
||||||
|
## Endpoints Admin API (Medusa v2) usados
|
||||||
|
|
||||||
|
- `GET /admin/collections?title=X` — chequear si una colección ya existe.
|
||||||
|
- `POST /admin/collections` — crear colección (`title`, `handle`).
|
||||||
|
- `POST /admin/collections/:id/products` con body `{"add": [...], "remove": []}` — asignar productos a una colección.
|
||||||
|
- `POST /admin/product-categories/:id/products` con body `{"add": [...], "remove": []}` — asignar productos a una categoría (mismo patrón confirmado en el fix #2 de categorías).
|
||||||
|
|
||||||
|
Confirmado que **no existen** en esta versión de Medusa:
|
||||||
|
`POST /admin/products/:id/categories` (404), ni `collection_id`/`category_ids`
|
||||||
|
en el body de `PATCH /admin/products/:id` (400).
|
||||||
|
|
||||||
|
## Cómo correrlo
|
||||||
|
|
||||||
|
Requiere un JWT de admin (`ADMIN_JWT`) obtenido vía
|
||||||
|
`POST /auth/user/emailpass` — no se persiste en este repo. El
|
||||||
|
bloqueo del clasificador de auto-mode sobre escrituras en lote contra
|
||||||
|
producción aplica aquí igual que en el fix #2 de categorías (ver
|
||||||
|
memoria `feedback_auto_mode_classifier_hard_blocks`), así que el
|
||||||
|
`--apply` lo corre el usuario directamente, no Claude:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MEDUSA_BACKEND_URL=https://commerce.cruzcloud.net
|
||||||
|
export ADMIN_JWT=<token>
|
||||||
|
|
||||||
|
# 1. Verificar el plan sin escribir nada
|
||||||
|
python3 scripts/migrate_legacy_collections.py --dry-run
|
||||||
|
|
||||||
|
# 2. Ejecutar
|
||||||
|
python3 scripts/migrate_legacy_collections.py --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Cada asignación es aditiva (`add`) sobre relaciones many-to-many, no
|
||||||
|
destructiva de datos previos. Para revertir, llamar los mismos
|
||||||
|
endpoints con `{"remove": [...ids], "add": []}` con los mismos
|
||||||
|
product_id/collection_id/category_id documentados arriba. Las
|
||||||
|
colecciones creadas ("Toy Story", "Intensamente", "Lilo & Stitch")
|
||||||
|
se pueden borrar con `DELETE /admin/collections/:id` si hace falta
|
||||||
|
deshacer la migración por completo.
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
#!/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()
|
||||||
Reference in new issue
Block a user