Signature
def apply_full_restore_from_zip(org, zip_path: str, rack_ids=None, mode: str = "add", tree_selection=None) -> dict:
"""Procesa un ZIP de backup y aplica las entidades bajo `org`.
Devuelve {"counts": {...}, "updated": {...}, "verification": {...}} — lo
creado, lo machacado (solo mode="update") y el informe esperado-vs-aplicado.
Con `rack_ids` restaura SOLO esos racks (lista vacía incluida desde v1.73.0
— task #236, PR #402); los dominios transversales viajan enteros.
`mode` (v1.75.0, task #236 ventana): "add" (histórico) recrea todo junto a
lo existente; "update" machaca por identidad natural lo que coincide, añade
lo nuevo, y nunca borra lo que la organización tiene y el backup no trae.
`tree_selection` (v1.76.0, task #236 ventana): selección de ÁRBOL del modal
de restauración (mismo esquema que la creación selectiva, IDs de la maleta).
Si no es None, PREVALECE sobre `rack_ids` — se poda con `filter_backup_by_tree()`
en vez de `filter_backup_selection()`.
"""
Llamador: restore_full() endpoint (via Huey task run_full_restore).
Timeout: ~10-15 minutos.
Transacción: Todo-o-nada (rollback si error).
Descripción
Restaura ~13 modelos desde el backup_data.json extraído del ZIP. Si falla cualquier modelo, rollback completo. Responde con contadores (n_racks, n_devices, etc.).
Flujo
- Parse
backup_data["organization"]→ verifica que sea la org actual. - Dentro de
transaction.atomic():- Clear tablas (racks, devices, etc.).
- Restaura en orden (org → groups → racks → devices → configs, etc.).
- Crea relaciones FK.
- Si error en cualquier paso → rollback automático.
- Retorna
{"racks": 50, "devices": 450, "success": true}.
DeviceProfile — restauración completa de la Ficha Central (v1.72.1, task #236, PR #400)
get_or_create() sobre organization + ip_address ahora restaura, en defaults, los campos que el serializador de backup dejaba fuera hasta v1.72.0: assigned_page, role, location, notes, manual_fields y las credenciales propias de la ficha (SNMP v3, SSH — cifradas, se restauran tal cual estaban en el ZIP). Ver el origen del fix en [[entity—racks—service—run-full-backup]].
- ZIPs legacy (previos al 20-08-2026): no traen
assigned_pageen el JSON.network.services.page_assignment.derive_assigned_page(device_type=...)deriva la clasificación de página a partir deldevice_typedel perfil, para que Wireless/UPS/DSM no lleguen sin página asignada. - Grupos: si el
DeviceProfilese crea (no existía por esa IP), susgroup_idsdel backup se remapean víagroup_mapy se aplican conobj.groups.set(...). Si el perfil ya existía (created=False), los grupos no se tocan — mismo criterio que el resto del restore (merge, no replace).
SignagePlayer — deduplicación en restore repetido (v1.72.2, task #236, PR #401)
La restauración de los dominios extendidos (Signage, ITSM, DCIM…) vive en racks/api/export/restore_domains.py, función restore_extended_domains(), invocada desde apply_full_restore_from_zip() (restore.py:502) dentro de la misma transacción todo-o-nada.
SignagePlayer tiene unique_together (organization, device_profile) y su DeviceProfile asociado deduplica entre restores (ver sección anterior). Hasta v1.72.1, un segundo restore del mismo backup sobre la misma organización encontraba el DeviceProfile ya existente pero intentaba crear otro SignagePlayer sobre esa misma ficha → UniqueViolation en signage_signageplayer, que tumbaba la transacción entera (no solo el player) con el mensaje genérico “Restore failed”. Cazado por Edu en el ensayo (Ensayo01, 20-08-2026).
Fix: restore_extended_domains() ahora usa get_or_create(organization=org, device_profile_id=new_profile_id, defaults={...}) cuando el player tiene ficha de dispositivo vinculada. Si created=False (ya existía), no se tocan sus grupos ni se incrementa el contador — el informe de verificación del restore lo cuenta como already_existed_or_skipped. Sin ficha vinculada (device_profile_id nulo) no hay clave natural para deduplicar: el player se crea siempre, duplicando como el resto de dominios extendidos (comportamiento “restore ADDS”, ver [[feature—backup-restore—additive-restore-confirmation-v1-66-2]]).
Es el único modelo de los dominios extendidos con unicidad sobre una clave que además deduplica — por eso era el único que reventaba la transacción en un segundo restore; el resto de entidades de esos dominios no tiene esa combinación y simplemente duplica (ADDS).
Selección vacía también es un restore válido — flag selective + inventario completo en la UI (v1.73.0, task #236, PR #402)
Tras el ensayo, Edu pidió poder restaurar solo los dominios transversales (stencils, grupos, monitorización, Signage, ITSM) sin traer ningún rack — y ver antes de confirmar qué contiene realmente la maleta, no solo la lista de racks.
- Bug de lista-vacía-es-falsa (poda):
apply_full_restore_from_zipcomprobabaif rack_ids:— una lista[](cero racks marcados) se evaluaba igual que “sin selección” y disparaba un restore completo, justo lo contrario de lo pedido. Fix:if rack_ids is not None:— ahora[]sí poda todos los racks y solo deja pasar los dominios transversales. - Mismo bug en la tarea Huey:
run_full_restore()(racks/tasks.py) calculabaselective: bool(rack_ids)para el resultado del job — conrack_ids=[]marcabaselective=Falsecuando en realidad SÍ fue una restauración selectiva. Cambiado arack_ids is not None, mismo criterio que el endpoint. - Endpoint
restore_full: nuevo parámetroselective: bool = Form(False). El frontend lo manda atrueen cuanto la selección es parcial (incluida la vacía):parsed_ids if (selective or parsed_ids) else None. Sin el flag (compat con clientes viejos), un CSV vacío se sigue leyendo como “restaurar todo” — el flag es lo que distingue “cero racks a propósito” de “no mandé nada”. - UI (
templates/base.html,static/js/base.js): el modal de selección incorpora un desplegable “What’s inside this backup” con TODOS los dominios del manifest y su conteo (antes solo se veían los racks). El texto de confirmación cambia según haya racks marcados o no, dejando explícito que 0 racks = “restaura solo los dominios compartidos”, no un error. - El diagnóstico paralelo de por qué Wireless/UPS/DSM llegaban vacíos en el ensayo (ver sección anterior, v1.72.1) se cerró reproduciendo el ZIP real de Edu en una organización limpia: el motor de restore estaba sano — la organización de ensayo estaba contaminada por pruebas previas, no un bug del restore.
- Tests:
tests/api/test_restore_selective.pycubre el casorack_ids=[]explícitamente. Área en Docker: 32 tests + mypy en verde.
Ver el flujo completo (preview, selección, informe) en [[feature—racks—restore-selectivo-v171]].
Modo Actualizar — “machaca lo que coincide, nunca borra” (v1.75.0, task #236 ventana)
Junto al histórico modo “añadir” (todo se recrea, duplicando lo que ya existe), el restore acepta desde v1.75.0 un segundo modo explícito: mode="update". Decidido con Edu en el grill del 21-08 (opción A: el modo es SIEMPRE una elección global del usuario, nunca automático ni mezclado dentro de la misma restauración). Detalle de valor de usuario en [[feature—backup-restore—modo-actualizar-v1-75-0]]; aquí el resumen de implementación:
- Identidad natural por modelo: en modo actualizar, cada entidad se busca antes de crearse por su clave “de negocio” (nombre del rack o plano, IP del target/ficha, título del runbook, device+puerto del cable, nombre de playlist/schedule/proyecto…) en vez de por su ID interno del ZIP. Si existe, se machaca; si no, se crea igual que en modo añadir. Los devices se emparejan por nombre dentro de su mismo rack.
- Helpers centralizados en
restore_update.py(nuevo módulo):apply_fields(obj, fields)escribe y guarda SOLO si algo cambió de verdad (evita saves y señales inútiles cuando backup y organización ya coinciden);upsert(model, match, fields, counts, updated, key)es el patrón repetido enrestore.pyyrestore_domains.pypara cada modelo con identidad natural. updatedviaja paralelo acounts: cada dominio (restore_extended_domains()incluido) acumula en un segundo diccionario lo machacado; en modo añadir queda vacío. La verificación esperado-vs-aplicado ahora suma creado+actualizado — un re-restore perfecto en modo actualizar dacomplete: truecon 0 creados.- Nunca se borra, en ningún modo: lo que vive en la organización y no viene en el backup se queda intacto siempre — actualizar no es sincronizar. Casos finos: las anotaciones de un plano existente se conservan (no hay clave natural para machacarlas sin arriesgar el dibujo del usuario), el status vivo de un
SignagePlayerno se pisa (lo gobierna su telemetría), los niveles de unaEscalationPolicyse reemplazan enteros, y los vínculos ficha↔device/target solo se machacan si el backup trae uno mapeado. - Validación todo-o-nada sobre el resultado FUSIONADO: tras aplicar el modo actualizar se revalida el rango y los solapes de U de cada rack sobre el estado final (lo existente + lo fusionado); un conflicto rechaza la transacción entera, igual que en un restore normal.
- Reorganización de ficheros (Regla 5):
restore.pysuperaba las 500 LOC de lógica; las secciones de racks y planos se movieron aracks/api/export/restore_inventory.py(268 LOC nuevas). La firma pública y el punto de entrada (apply_full_restore_from_zip) no cambian de sitio. - La UI (selector de modo en el modal de restauración) no llega en esta pieza — sigue en la siguiente de la ventana del task #236.
Selección por árbol en la restauración — tree_selection (v1.76.0, task #236 ventana)
Tercera pieza de la ventana: el parámetro rack_ids (lista plana de IDs) convive ahora con tree_selection, la selección de ÁRBOL que manda el modal de restauración nuevo — mismo esquema {blueprint_ids, include_nomap, domains} que la creación selectiva ([[feature—backup-restore—creacion-selectiva-v1-74-0]]), pero con los IDs referidos a la MALETA (no hay organización origen a mano para resolverlos). Si tree_selection no es None, prevalece sobre rack_ids, que queda como compat para clientes viejos.
- La poda vive en el nuevo
racks/api/export/restore_preview.py:filter_backup_by_tree(data, selection)resuelve las secciones desde la propia maleta (misma regla del primer plano quebuild_restore_tree()) y reutilizafilter_backup_selection()(racks) + laprune_domains()ahora compartida conbackup_selection.py(dominios). restore_preview()(el endpoint de precarga) cambia de forma: en vez de la lista planaracks, devuelvetree— el mismo árbol que ve el usuario al crear un backup, pero construido desde el ZIP y con banderasexists/existingcontra la organización destino — ypartial(si el backup subido era parcial).restore_full()ganaselection: str = Form("")(JSON), normalizado connormalize_selection()antes de pasarlo comotree_selectiona la tarea Huey.- Detalle completo de la pieza (valor de usuario, selector de modo en la UI) en [[feature—backup-restore—restauracion-arbol-v1-76-0]].
Extracción de medios directa al org destino — incidente 20-08 (v1.77.2, task #236 ventana)
Cuarta pieza de la ventana, esta vez el fix de un incidente: detalle completo (síntomas, causa raíz, recuperación) en [[incident—20260820—restore-roba-medios-signage-org-origen]]. Restaurar en la MISMA instalación (org origen y destino comparten MEDIA_ROOT) con un backup SIN vídeos hacía que relocate_signage_files() moviera (os.replace) los ficheros ORIGINALES vivos del org origen — la función no distinguía “extraído del ZIP” de “ya existente en esa ruta”. Vació la biblioteca de Signage de una organización real durante un ensayo en la organización de pruebas; recuperado desde el ZIP de un backup anterior.
- La extracción de
signage/dentro deapply_full_restore_from_zip()remapea ahora la ruta al org destino EN EL MOMENTO de extraer (_remap_signage_path()aplicado antes de escribir a disco), no en un paso de recolocación posterior. relocate_signage_files()queda RETIRADA derestore_domains.py.- Los ficheros no empaquetados en el ZIP se COPIAN (
shutil.copy2, nuncaos.replace) desde el org origen al crear cadaMediaAssetvía_ensure_media_file()— el original del org origen se conserva siempre. - Test de regresión
test_restore_without_videos_never_moves_source_files(tests/api/test_backup_scope.py) fija el contrato: rojo con el código anterior, verde con el fix.
Aviso de enlaces de publicación nuevos en Signage — notices (v1.124.0, task #276)
Quinta pieza: restore_extended_domains() devuelve ahora un segundo valor, un dict notices, que apply_full_restore_from_zip() reenvía tal cual en la clave notices de su resultado, y que run_full_restore() (racks/tasks.py) copia al resultado del AsyncJob para que llegue al frontend.
Por qué: el publish_token de SignagePlayer se excluye del backup a propósito (ver más arriba, sección “Fuera a propósito” del valor de usuario de esta página), así que cada player CREADO por un restore estrena un token nuevo — el aparato físico (SpinetiX) sigue pidiendo la URL vieja hasta que alguien lo reconfigura a mano. Hasta esta pieza, ese cambio era silencioso: el CCIB estuvo 9 días con una pantalla sin actualizar tras un restore (ronda 30-08) sin que nadie supiera por qué.
notices["signage_players_new_links"]: lista de{player_id, display_name, publish_token}, solo para players concreated=Trueen elget_or_create— un player que coincide por identidad natural en modoupdateconserva su token y no necesita aviso.- UI: el informe de restore (
templates/base.html#restore-report-notices,static/js/base.js) pinta un aviso con la URL completa de cada player nuevo (?v=debase.js11→12). - Red de seguridad si el aviso se ignora: cada 404 real contra
/publish/<token>/de un token inexistente/revocado/caducado queda anotado por [[entity—signage—service—publish-misses]], visible en la pestaña Historial de Signage — así se detecta también el caso en que nadie leyó el informe del restore. - Tests:
tests/signage/test_ronda_0906_tanda3.py(9, cubre también las otras dos piezas del mismo commit — expiración deAsyncJobcolgados en [[entity—core—model—asyncjob]] y el registro deSignageOperationenclient_publish).
Seguridad
- RLS: Restaura solo dentro de la org actual.
- Creds: SNMP/SSH creds se restauran cifradas tal cual venían en el ZIP (no se descifran ni re-cifran en este paso) — solo son utilizables en la misma instalación que generó el backup.
- Uploads: Solo restaura si archivos existen en disco (omite si no están). Desde v1.77.2, la copia (nunca movida) — ver sección de arriba.
Véase también
- [[entity—racks—endpoint—backup-full-start]]
- [[entity—racks—service—run-full-backup]]
- [[feature—racks—backup-restore-async-v156]]
- [[feature—backup-restore—additive-restore-confirmation-v1-66-2]]
- [[feature—racks—restore-selectivo-v171]]
- [[feature—backup-restore—modo-actualizar-v1-75-0]]
- [[feature—backup-restore—restauracion-arbol-v1-76-0]]
- [[concept—saas—multi-tenancy]]
- [[concept—backend—async-jobs]]
- [[incident—20260820—restore-roba-medios-signage-org-origen]]
- [[entity—signage—service—publish-misses]]
Referenciado desde
- Auditoría Suprema 2 · Cola racks: librería de stencils, backup/restore, plantillas y rastro de auditoría
- Backup & Restore - CreaRack Pro
- Copia de seguridad y restauración completa en segundo plano (v1.56.0 · ADR T2 2/2)
- El modal de restauración enseña el mismo árbol, con badges "ya existe" y selector de modo (v1.76.0)
- El restore aprende a actualizar: machaca lo que coincide, nunca borra (v1.75.0)
- El restore robaba los medios de Signage del org origen al restaurar en la misma instalación (v1.77.2)
- Restore selectivo por rack + informe de verificación del restore (v1.71.0)
- Servicio / Tarea Huey `run_full_backup()` — Generar ZIP de backup (v1.56.0)
- Servicio publish_misses — enlaces de publicación de Signage rechazados (v1.124.0, task #276)