Repository: datosgobar/pydatajson

Issue:
Push organizations to ckan
Dado un árbol jerárquico de organizaciones, el objetivo es crear las organizaciones allí descriptas, manteniendo la jerarquía original

Discussion:

Patch:
diff --git a/.gitignore b/.gitignore
index e6e7bf3..35e9136 100644
--- a/.gitignore
+++ b/.gitignore
@@ -74,7 +74,6 @@ TODO.md
 
 # Archivos temporales de prueba
 tests/temp/*
-tests/results/catalog/*
 .ipynb_checkpoints
 samples/archivos-generados/*
 .DS_Store
diff --git a/HISTORY.md b/HISTORY.md
index f99fae6..ae16245 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,12 +1,6 @@
 Versiones
 =========
 
-0.4.25 (2018-10-22)
--------------------
-* Agrega indicador 'datasets_con_datos_cant' para identificar la cantidad de datasets que tienen alguna distribución potencialmente con datos y los que no.
-* Expande la función `backup.make_catalogs_backup()` con argumentos opcionales para facilitar la generación de backups descargando las distribuciones.
-
-
 0.4.24 (2018-10-16)
 -------------------
 * Cambia el valor default en el indicador `datasets_frecuencias_cant`.
@@ -52,7 +46,6 @@ Versiones
 * Validación de keywords, themes, y lenguajes vacíos.
 * Bugfix en `distribution_has_time_index` para capturar excepciones en field inválidos.
 
-
 0.4.17 (2018-07-10)
 -------------------
 
diff --git a/docs/MANUAL.md b/docs/MANUAL.md
index e03b74f..8a0c2f6 100644
--- a/docs/MANUAL.md
+++ b/docs/MANUAL.md
@@ -438,6 +438,19 @@ Toma los siguientes parámetros:
   Retorna una lista de diccionarios con la información de las organizaciones. Recursivamente, dentro del campo `children`,
   se encuentran las organizaciones dependientes en la jerarquía. 
 
+- **pydatajson.federation.push_organization_tree_to_ckan()**: Tomando un árbol de organizaciones como el creado por
+`get_organizations_from_ckan()` crea en el portal de destino las organizaciones dentro de su jerarquía. Toma los siguientes
+parámetros:
+  - **portal_url**: La URL del portal CKAN de destino.
+  - **apikey**: La apikey de un usuario con los permisos que le permitan crear las organizaciones.
+  - **org_tree**: lista de diccionarios con la data de organizaciones a crear.
+  - **parent** (opcional, default: None): Si se pasa, el árbol de organizaciones pasado en `org_tree` se
+  crea bajo la organización con `name` pasado en `parent`. Si no se pasa un parámetro, las organizaciones son creadas
+  como primer nivel.
+  
+  Retorna el árbol de organizaciones creadas. Cada nodo tiene un campo `success` que indica si fue creado exitosamente o
+  no. En caso de que `success` sea False, los hijos de esa organización no son creados.
+
 ## Anexo I: Estructura de respuestas
 
 ### validate_catalog()
diff --git a/pydatajson/__init__.py b/pydatajson/__init__.py
index eac4dd6..cd46a0c 100644
--- a/pydatajson/__init__.py
+++ b/pydatajson/__init__.py
@@ -13,7 +13,7 @@ import logging
 
 __author__ = """Datos Argentina"""
 __email__ = 'datos@modernizacion.gob.ar'
-__version__ = '0.4.25'
+__version__ = '0.4.24'
 
 """
 Logger base para librería pydatajson
diff --git a/pydatajson/backup.py b/pydatajson/backup.py
index 2ee5544..6e4a7a8 100644
--- a/pydatajson/backup.py
+++ b/pydatajson/backup.py
@@ -7,7 +7,7 @@ from __future__ import unicode_literals
 from __future__ import print_function
 from __future__ import with_statement
 import os
-import sys
+import traceback
 import logging
 
 import pydatajson
@@ -21,7 +21,7 @@ logger = logging.getLogger('pydatajson')
 
 def make_catalogs_backup(catalogs, local_catalogs_dir="",
                          include_metadata=True, include_data=True,
-                         include_metadata_xlsx=False, use_short_path=False):
+                         include_metadata_xlsx=False):
     """Realiza una copia local de los datos y metadatos de un catálogo.
 
     Args:
@@ -57,8 +57,7 @@ def make_catalogs_backup(catalogs, local_catalogs_dir="",
                     local_catalogs_dir=local_catalogs_dir,
                     include_metadata=include_metadata,
                     include_metadata_xlsx=include_metadata_xlsx,
-                    include_data=include_data,
-                    use_short_path=use_short_path)
+                    include_data=include_data)
             except Exception:
                 logger.exception("ERROR en {}".format(catalog))
 
@@ -70,8 +69,7 @@ def make_catalogs_backup(catalogs, local_catalogs_dir="",
                     local_catalogs_dir=local_catalogs_dir,
                     include_metadata=include_metadata,
                     include_metadata_xlsx=include_metadata_xlsx,
-                    include_data=include_data,
-                    use_short_path=use_short_path)
+                    include_data=include_data)
             except Exception:
                 logger.exception(
                     "ERROR en {} ({})".format(catalog, catalog_id))
@@ -79,9 +77,7 @@ def make_catalogs_backup(catalogs, local_catalogs_dir="",
 
 def make_catalog_backup(catalog, catalog_id=None, local_catalogs_dir="",
                         include_metadata=True, include_data=True,
-                        include_datasets=[],
-                        include_distribution_formats=['CSV', 'XLS'],
-                        include_metadata_xlsx=True, use_short_path=False):
+                        include_metadata_xlsx=True):
     """Realiza una copia local de los datos y metadatos de un catálogo.
 
     Args:
@@ -90,21 +86,14 @@ def make_catalog_backup(catalog, catalog_id=None, local_catalogs_dir="",
             archivo con la metadata de un catálogo, en formato JSON o XLSX. La
             representación _interna_ de un catálogo es un diccionario.
         catalog_id (str): Si se especifica, se usa este identificador para el
-            backup. Si no se especifica, se usa catalog["identifier"].
+            backup. Si no se espedifica, se usa catalog["identifier"].
         local_catalogs_dir (str): Directorio local en el cual se va a crear
             la carpeta "catalog/..." con todos los catálogos.
         include_metadata (bool): Si es verdadero, se generan los archivos
             data.json y catalog.xlsx.
         include_data (bool): Si es verdadero, se descargan todas las
             distribuciones de todos los catálogos.
-        include_datasets (list): Si se especifica, se descargan únicamente los
-            datasets indicados. Si no, se descargan todos.
-        include_distribution_formats (list): Si se especifica, se descargan
-            únicamente las distribuciones de los formatos indicados. Si no, se
-            descargan todas.
-        use_short_path (bool): No implementado. Si es verdadero, se utiliza una
-            jerarquía de directorios simplificada. Caso contrario, se replica
-            la existente en infra.
+
     Return:
         None
     """
@@ -138,75 +127,45 @@ def make_catalog_backup(catalog, catalog_id=None, local_catalogs_dir="",
                 index + 1, distributions_num, catalog_identifier))
 
             dataset_id = distribution["dataset_identifier"]
+            distribution_id = distribution["identifier"]
+            distribution_download_url = distribution["downloadURL"]
+
+            # si no se especifica un file name, se toma de la URL
+            distribution_file_name = distribution.get(
+                "fileName",
+                distribution_download_url[
+                    distribution_download_url.rfind("/") + 1:]
+            )
+
+            # genera el path local donde descargar el archivo
+            file_path = get_distribution_path(
+                catalog_identifier, dataset_id, distribution_id,
+                distribution_file_name, local_catalogs_dir)
+            ensure_dir_exists(os.path.dirname(file_path))
 
-            if include_datasets and (dataset_id not in include_datasets):
-                pass
-            else:
-                distribution_id = distribution["identifier"]
-                distribution_download_url = distribution["downloadURL"]
-
-                # si no se especifica un file name, se toma de la URL
-                distribution_file_name = distribution.get(
-                    "fileName",
-                    distribution_download_url[
-                        distribution_download_url.rfind("/") + 1:]
-                )
-
-                # si no espicifica un formato, toma de distribution_file_name
-                # asume que formato está al menos en distribution_file_name
-                distribution_format = distribution.get(
-                    "format",
-                    distribution_file_name[
-                        distribution_file_name.rfind(".") + 1:]
-                )
-                if (include_distribution_formats and
-                        (distribution_format
-                            not in include_distribution_formats)):
-                    pass
-                else:
-
-                    # genera el path local donde descargar el archivo
-                    file_path = get_distribution_path(
-                        catalog_identifier, dataset_id, distribution_id,
-                        distribution_file_name, local_catalogs_dir,
-                        use_short_path=use_short_path)
-                    ensure_dir_exists(os.path.dirname(file_path))
-
-                    # decarga el archivo
-                    download_to_file(distribution_download_url, file_path)
+            # decarga el archivo
+            download_to_file(distribution_download_url, file_path)
 
 
 def get_distribution_dir(catalog_id, dataset_id, distribution_id,
-                         catalogs_dir=CATALOGS_DIR, use_short_path=False):
+                         catalogs_dir=CATALOGS_DIR):
     """Genera el path estándar de un catálogo en un filesystem."""
-    if use_short_path:
-        catalog_path = os.path.join(catalogs_dir, "catalog", catalog_id)
-        distribution_dir = os.path.join(catalog_path, dataset_id)
-    else:
-        catalog_path = os.path.join(catalogs_dir, "catalog", catalog_id)
-        dataset_path = os.path.join(catalog_path, "dataset", dataset_id)
-        distribution_dir = os.path.join(
-            dataset_path, "distribution", distribution_id)
+    catalog_path = os.path.join(catalogs_dir, "catalog", catalog_id)
+    dataset_path = os.path.join(catalog_path, "dataset", dataset_id)
+    distribution_dir = os.path.join(dataset_path, "distribution",
+                                    distribution_id)
 
     return os.path.abspath(distribution_dir)
 
 
 def get_distribution_path(catalog_id, dataset_id, distribution_id,
-                          distribution_file_name, catalogs_dir=CATALOGS_DIR,
-                          use_short_path=False):
+                          distribution_file_name, catalogs_dir=CATALOGS_DIR):
     """Genera el path estándar de un catálogo en un filesystem."""
-    if use_short_path:
-        distribution_dir = get_distribution_dir(
-            catalog_id, dataset_id, distribution_id, catalogs_dir,
-            use_short_path=True)
-        distribution_file_path = os.path.join(
-            distribution_dir, distribution_file_name)
-    else:
-        distribution_dir = get_distribution_dir(
-            catalog_id, dataset_id, distribution_id, catalogs_dir,
-            use_short_path=False)
-        distribution_file_path = os.path.join(
-            distribution_dir, "download", distribution_file_name)
+
+    distribution_dir = get_distribution_dir(
+        catalog_id, dataset_id, distribution_id, catalogs_dir)
+    distribution_file_path = os.path.join(
+        distribution_dir, "download", distribution_file_name)
 
     return os.path.abspath(distribution_file_path)
 
@@ -224,7 +183,7 @@ def get_catalog_path(catalog_id, catalogs_dir=CATALOGS_DIR, fmt="json"):
             fmt))
 
 
-def main(catalogs, include_data=True, use_short_path=True):
+def main(catalogs, include_data=True):
     """Permite hacer backups de uno o más catálogos por línea de comandos.
 
     Args:
@@ -232,8 +191,7 @@ def main(catalogs, include_data=True, use_short_path=True):
             locales) para hacer backups.
     """
     include_data = bool(int(include_data))
-    make_catalogs_backup(catalogs.split(
-        ","), include_data=include_data, use_short_path=use_short_path)
+    make_catalogs_backup(catalogs.split(","), include_data=include_data)
 
 
 if __name__ == '__main__':
diff --git a/pydatajson/ckan_reader.py b/pydatajson/ckan_reader.py
index b92a636..e14b4fe 100644
--- a/pydatajson/ckan_reader.py
+++ b/pydatajson/ckan_reader.py
@@ -9,7 +9,6 @@ from __future__ import unicode_literals, print_function,\
 import os.path
 import logging
 import json
-import time
 from six.moves.urllib_parse import urljoin
 from six import iteritems
 from ckanapi import RemoteCKAN
@@ -70,9 +69,6 @@ def read_ckan_catalog(portal_url):
                 requests_kwargs={"verify": False}
             ))
 
-            # tiempo de espera padra evitar baneos
-            time.sleep(0.2)
-
         # itera leyendo todos los temas del portal
         groups = [portal.call_action(
             'group_show', {'id': grp},
@@ -87,7 +83,6 @@ def read_ckan_catalog(portal_url):
     except Exception as e:
         logger.exception(
             'Error al procesar el portal %s', portal_url, exc_info=True)
-        print(e)
 
     return catalog
 
diff --git a/pydatajson/core.py b/pydatajson/core.py
index 6a4ca14..09f1b5e 100644
--- a/pydatajson/core.py
+++ b/pydatajson/core.py
@@ -42,6 +42,13 @@ logger = logging.getLogger('pydatajson')
 
 ABSOLUTE_PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
 CENTRAL_CATALOG = "http://datos.gob.ar/data.json"
+DATA_FORMATS = [
+    "csv", "xls", "xlsx", "ods", "dta",
+    "shp", "kml",
+    "json", "xml",
+    "zip", "rar",
+    "html", "php"
+]
 MIN_DATASET_TITLE = 10
 MIN_DATASET_DESCRIPTION = 20
 
@@ -378,7 +385,7 @@ class DataJson(dict):
 
         # crea lista de formatos
         distributions_formats = json.dumps(
-            helpers.count_distribution_formats_dataset(dataset))
+            cls._count_distribution_formats_dataset(dataset))
 
         fields = OrderedDict()
         fields["dataset_identifier"] = dataset.get("identifier")
@@ -493,7 +500,15 @@ el argumento 'report'. Por favor, intentelo nuevamente.""")
 
         # 1. VALIDACIONES
         # chequea que haya por lo menos algún formato de datos reconocido
-        has_data_format = helpers.dataset_has_data_distributions(dataset)
+        has_data_format = False
+        formats = self._count_distribution_formats_dataset(dataset).keys()
+        for distrib_format in formats:
+            for data_format in DATA_FORMATS:
+                if data_format.lower() in distrib_format.lower():
+                    has_data_format = True
+                    break
+            if has_data_format:
+                break
 
         # chequea que algunos campos tengan longitudes mínimas
         has_title = "title" in dataset
@@ -1002,7 +1017,7 @@ Por favor, consulte el informe [`datasets.csv`](datasets.csv).
         catalog_readme = readme_template.format(**content)
 
         if export_path:
-            with io.open(export_path, 'w+', encoding='utf-8') as target:
+            with io.open(export_path, 'w', encoding='utf-8') as target:
                 target.write(catalog_readme)
         else:
             return catalog_readme
@@ -1059,6 +1074,22 @@ El reporte no contiene la clave obligatoria {}. Pruebe con otro archivo.
         return indicators.generate_catalogs_indicators(
             catalogs, central_catalog, validator=self.validator)
 
+    @staticmethod
+    def _count_distribution_formats_dataset(dataset):
+
+        formats = {}
+        for distribution in dataset['distribution']:
+            # 'format' es recomendado, no obligatorio. Puede no estar.
+            distribution_format = distribution.get('format', None)
+
+            if distribution_format:
+                # Si no está en el diccionario, devuelvo 0
+                count = formats.get(distribution_format, 0)
+
+                formats[distribution_format] = count + 1
+
+        return formats
+
     def _count_fields_recursive(self, dataset, fields):
         """Cuenta la información de campos optativos/recomendados/requeridos
         desde 'fields', y cuenta la ocurrencia de los mismos en 'dataset'.
diff --git a/pydatajson/documentation.py b/pydatajson/documentation.py
index 48d8b99..51745d1 100644
--- a/pydatajson/documentation.py
+++ b/pydatajson/documentation.py
@@ -47,10 +47,12 @@ def dataset_to_markdown(dataset):
 
 
 def distribution_to_markdown(distribution):
-    """Genera texto en markdown a partir de los metadatos de una `distribution`.
+    """Genera texto en markdown a partir de los metadatos de una
+    `distribution`.
 
     Args:
-        distribution (dict): Diccionario con metadatos de una `distribution`.
+        distribution (dict): Diccionario con metadatos de una
+        `distribution`.
 
     Returns:
         str: Texto que describe una `distribution`.
diff --git a/pydatajson/federation.py b/pydatajson/federation.py
index 1138121..fa0b31b 100644
--- a/pydatajson/federation.py
+++ b/pydatajson/federation.py
@@ -286,7 +286,7 @@ def push_new_themes(catalog, portal_url, apikey):
                 taxonomía.
             portal_url (str): La URL del portal CKAN de destino.
             apikey (str): La apikey de un usuario con los permisos que le
-                permitan crear o actualizar el dataset.
+                permitan crear o actualizar los temas.
         Returns:
             str: Los ids de los temas creados.
     """
@@ -314,3 +314,42 @@ def get_organizations_from_ckan(portal_url):
     ckan_portal = RemoteCKAN(portal_url)
     return ckan_portal.call_action('group_tree',
                                    data_dict={'type': 'organization'})
+
+
+def push_organization_tree_to_ckan(portal_url, apikey, org_tree, parent=None):
+    """Toma un árbol de organizaciones y lo replica en el portal de
+    destino.
+
+            Args:
+                portal_url (str): La URL del portal CKAN de destino.
+                apikey (str): La apikey de un usuario con los permisos que le
+                    permitan crear las organizaciones.
+                org_tree(list): lista de diccionarios con la data de las
+                    organizaciones a crear.
+                parent(str): campo name de la organizacion padre.
+            Returns:
+                (list): Devuelve el arbol de organizaciones recorridas,
+                    junto con el status detallando si la creación fue
+                    exitosa o no.
+
+    """
+    portal = RemoteCKAN(portal_url, apikey=apikey)
+    created = []
+    for node in org_tree:
+        if parent:
+            node['groups'] = [{'name': parent}]
+        try:
+            pushed_org = portal.call_action('organization_create',
+                                            data_dict=node)
+            pushed_org['success'] = True
+        except Exception as e:
+            logger.exception('Ocurrió un error creando la organización {}: {}'
+                             .format(node['title'], str(e)))
+            pushed_org = {'name': node, 'success': False}
+
+        if pushed_org['success']:
+            pushed_org['children'] = push_organization_tree_to_ckan(
+                portal_url, apikey, node['children'], parent=node['name'])
+
+        created.append(pushed_org)
+    return created
diff --git a/pydatajson/helpers.py b/pydatajson/helpers.py
index 1e0b48c..e697c8b 100644
--- a/pydatajson/helpers.py
+++ b/pydatajson/helpers.py
@@ -26,43 +26,6 @@ STOP_WORDS = [
     "y", "a",
     "un", "una", "en"
 ]
-DATA_FORMATS = [
-    "csv", "xls", "xlsx", "ods", "dta",
-    "shp", "kml",
-    "json", "xml",
-    "zip", "rar",
-    "html", "php"
-]
-
-
-def count_distribution_formats_dataset(dataset):
-
-    formats = {}
-    for distribution in dataset['distribution']:
-            # 'format' es recomendado, no obligatorio. Puede no estar.
-        distribution_format = distribution.get('format', None)
-
-        if distribution_format:
-                # Si no está en el diccionario, devuelvo 0
-            count = formats.get(distribution_format, 0)
-
-            formats[distribution_format] = count + 1
-
-    return formats
-
-
-def dataset_has_data_distributions(dataset):
-    has_data_format = False
-    formats = count_distribution_formats_dataset(dataset).keys()
-    for distrib_format in formats:
-        for data_format in DATA_FORMATS:
-            if data_format.lower() in distrib_format.lower():
-                has_data_format = True
-                break
-        if has_data_format:
-            break
-
-    return has_data_format
 
 
 def title_to_name(title, decode=True, max_len=None, use_complete_words=True):
diff --git a/pydatajson/indicators.py b/pydatajson/indicators.py
index e6be6d4..9629656 100644
--- a/pydatajson/indicators.py
+++ b/pydatajson/indicators.py
@@ -27,7 +27,6 @@ CENTRAL_CATALOG = "http://datos.gob.ar/data.json"
 ABSOLUTE_PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
 CATALOG_FIELDS_PATH = os.path.join(ABSOLUTE_PROJECT_DIR, "fields")
 
-
 logger = logging.getLogger('pydatajson')
 
 
@@ -118,16 +117,12 @@ def _generate_indicators(catalog, validator=None, only_numeric=False):
     Returns:
         dict: diccionario con los indicadores del catálogo provisto
     """
-
     result = {}
-
     # Obtengo summary para los indicadores del estado de los metadatos
     result.update(_generate_status_indicators(catalog, validator=validator))
-
     # Genero los indicadores relacionados con fechas, y los agrego
     result.update(
         _generate_date_indicators(catalog, only_numeric=only_numeric))
-
     # Agrego la cuenta de los formatos de las distribuciones
     if not only_numeric:
         if 'dataset' in catalog:
@@ -326,10 +321,7 @@ def _generate_status_indicators(catalog, validator=None):
         'distribuciones_cant': None,
         'datasets_meta_ok_cant': None,
         'datasets_meta_error_cant': None,
-        'datasets_meta_ok_pct': None,
-        'datasets_con_datos_cant': None,
-        'datasets_sin_datos_cant': None,
-        'datasets_con_datos_pct': None
+        'datasets_meta_ok_pct': None
     }
     try:
         summary = generate_datasets_summary(catalog, validator=validator)
@@ -341,43 +333,25 @@ def _generate_status_indicators(catalog, validator=None):
 
     cant_ok = 0
     cant_error = 0
-    cant_data = 0
-    cant_without_data = 0
     cant_distribuciones = 0
     datasets_total = len(summary)
     for dataset in summary:
         cant_distribuciones += dataset['cant_distribuciones']
 
-        print(dataset)
-        # chequea si el dataset tiene datos
-        if dataset['tiene_datos'] == "SI":
-            cant_data += 1
-        else:  # == "ERROR"
-            cant_without_data += 1
-
-        # chequea estado de los metadatos
         if dataset['estado_metadatos'] == "OK":
             cant_ok += 1
         else:  # == "ERROR"
             cant_error += 1
 
     datasets_ok_pct = 0
-    datasets_with_data_pct = 0
     if datasets_total:
         datasets_ok_pct = round(100 * float(cant_ok) / datasets_total, 2)
-        datasets_with_data_pct = round(
-            100 * float(cant_data) / datasets_total, 2)
-
     result.update({
         'datasets_cant': datasets_total,
         'distribuciones_cant': cant_distribuciones,
         'datasets_meta_ok_cant': cant_ok,
         'datasets_meta_error_cant': cant_error,
-        'datasets_meta_ok_pct': datasets_ok_pct,
-        'datasets_con_datos_cant': cant_data,
-        'datasets_sin_datos_cant': cant_without_data,
-        'datasets_con_datos_pct': datasets_with_data_pct
-
+        'datasets_meta_ok_pct': datasets_ok_pct
     })
     return result
 
diff --git a/pydatajson/reporting.py b/pydatajson/reporting.py
index 4d38c06..d376a3b 100644
--- a/pydatajson/reporting.py
+++ b/pydatajson/reporting.py
@@ -15,7 +15,6 @@ from pydatajson import writers
 from .validation import validate_catalog
 
 from . import readers
-from . import helpers
 
 
 def generate_datasets_summary(catalog, export_path=None, validator=None):
@@ -64,10 +63,6 @@ def generate_datasets_summary(catalog, export_path=None, validator=None):
         info["estado_metadatos"] = validation[index]["status"]
         info["cant_errores"] = len(validation[index]["errors"])
         info["cant_distribuciones"] = len(dataset["distribution"])
-        if helpers.dataset_has_data_distributions(dataset):
-            info["tiene_datos"] = "SI"
-        else:
-            info["tiene_datos"] = "NO"
 
         return info
 
diff --git a/setup.cfg b/setup.cfg
index ad1d067..cdebfe7 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 0.4.25
+current_version = 0.1.0
 commit = True
 tag = True
 
diff --git a/setup.py b/setup.py
index db86aaa..bc683e6 100644
--- a/setup.py
+++ b/setup.py
@@ -21,7 +21,7 @@ with open(os.path.abspath("requirements_2.7.txt")) as f:
 
 setup(
     name='pydatajson',
-    version='0.4.25',
+    version='0.4.24',
     description="Paquete en python con herramientas para generar y validar metadatos de catálogos de datos en formato data.json.",
     long_description=readme + '\n\n' + history,
     long_description_content_type='text/markdown',


Test patch:
diff --git a/tests/samples/organization_tree.json b/tests/samples/organization_tree.json
new file mode 100644
index 0000000..0e0ff6d
--- /dev/null
+++ b/tests/samples/organization_tree.json
@@ -0,0 +1,235 @@
+[
+    {
+        "highlighted": false, 
+        "title": " Jefatura de Gabinete de Ministros", 
+        "children": [
+            {
+                "highlighted": false, 
+                "title": "Agencia de Acceso a la Informaci\u00f3n P\u00fablica", 
+                "children": [], 
+                "name": "aaip", 
+                "id": "daa8b40c-fa37-478c-a7ef-081305aeadd8"
+            }, 
+            {
+                "highlighted": false, 
+                "title": "Secretar\u00eda de Gobierno de Ambiente y Desarrollo Sustentable", 
+                "children": [
+                    {
+                        "highlighted": false, 
+                        "title": "Autoridad de Cuenca Matanza Riachuelo", 
+                        "children": [], 
+                        "name": "acumar", 
+                        "id": "1b6b28cd-098f-41d7-b43f-d5a01ffa8759"
+                    }
+                ], 
+                "name": "ambiente", 
+                "id": "0e5aa328-825e-4509-851d-5421e866635e"
+            }, 
+            {
+                "highlighted": false, 
+                "title": "Secretar\u00eda de Gobierno de Modernizaci\u00f3n", 
+                "children": [
+                    {
+                        "highlighted": false, 
+                        "title": "Empresa Argentina de Soluciones Satelitales", 
+                        "children": [], 
+                        "name": "arsat", 
+                        "id": "b2509ac0-3af6-4f66-9ffa-8c6fb4206791"
+                    }, 
+                    {
+                        "highlighted": false, 
+                        "title": "Ente Nacional de Comunicaciones", 
+                        "children": [], 
+                        "name": "enacom", 
+                        "id": "6eb7d19b-2d42-494f-8e57-d67c501d23eb"
+                    }
+                ], 
+                "name": "modernizacion", 
+                "id": "4c7a6f6b-5caf-42a1-aae5-0a07e609a235"
+            }, 
+            {
+                "highlighted": false, 
+                "title": "Secretar\u00eda de Gobierno de Turismo", 
+                "children": [], 
+                "name": "turismo", 
+                "id": "3a751de6-128e-4f9a-a479-4672ef79a0e8"
+            }
+        ], 
+        "name": "jgm", 
+        "id": "f917ad65-28ea-42a9-81ae-61a2bb8f58d0"
+    }, 
+    {
+        "highlighted": false, 
+        "title": " Ministerio de Defensa", 
+        "children": [
+            {
+                "highlighted": false, 
+                "title": "Instituto Geogr\u00e1fico Nacional", 
+                "children": [], 
+                "name": "ign", 
+                "id": "9b47c8eb-bb5c-40df-bbaa-589374d14da8"
+            }, 
+            {
+                "highlighted": false, 
+                "title": "Servicio Meteorol\u00f3gico Nacional", 
+                "children": [], 
+                "name": "smn", 
+                "id": "dba2a17e-e2ea-4e0b-b0ef-bf4ffe9f9ad9"
+            }
+        ], 
+        "name": "defensa", 
+        "id": "5e80ed4f-8bfb-4451-8240-e8f39e695ee1"
+    }, 
+    {
+        "highlighted": false, 
+        "title": "Ministerio de Educaci\u00f3n, Cultura, Ciencia y Tecnolog\u00eda", 
+        "children": [
+            {
+                "highlighted": false, 
+                "title": "Secretar\u00eda de Gobierno de Ciencia y Tecnolog\u00eda", 
+                "children": [], 
+                "name": "mincyt", 
+                "id": "772ab9b7-056d-4ae4-b7a2-a1329e979690"
+            }, 
+            {
+                "highlighted": false, 
+                "title": "Secretar\u00eda de Gobierno de Cultura", 
+                "children": [], 
+                "name": "cultura", 
+                "id": "9fcc8ffc-3dcc-4437-acc8-72c5b08d8d51"
+            }
+        ], 
+        "name": "educacion", 
+        "id": "27d39ff7-7110-4c6d-b7e8-1b8eba392d7e"
+    }, 
+    {
+        "highlighted": false, 
+        "title": " Ministerio de Hacienda", 
+        "children": [
+            {
+                "highlighted": false, 
+                "title": "Secretar\u00eda de Gobierno de Energ\u00eda", 
+                "children": [
+                    {
+                        "highlighted": false, 
+                        "title": "Ente Nacional Regulador del Gas", 
+                        "children": [], 
+                        "name": "enargas", 
+                        "id": "dddda9d3-644d-4e3d-974b-302fd8945a86"
+                    }
+                ], 
+                "name": "energia", 
+                "id": "cd1b32a2-2d3a-44ac-8c98-a425a7b62d42"
+            }, 
+            {
+                "highlighted": false, 
+                "title": "Subsecretar\u00eda de Presupuesto", 
+                "children": [], 
+                "name": "sspre", 
+                "id": "a753aeb8-52eb-4cfc-a6ee-6d930094b0f2"
+            }, 
+            {
+                "highlighted": false, 
+                "title": "Subsecretar\u00eda de Programaci\u00f3n Macroecon\u00f3mica", 
+                "children": [], 
+                "name": "sspm", 
+                "id": "68f9ee22-5ec3-44cf-a32b-b87d9db46b93"
+            }, 
+            {
+                "highlighted": false, 
+                "title": "Subsecretar\u00eda de Programaci\u00f3n Microecon\u00f3mica", 
+                "children": [], 
+                "name": "sspmi", 
+                "id": "b4baa84a-16a7-4db0-af9c-bd925971d26a"
+            }
+        ], 
+        "name": "hacienda", 
+        "id": "b2a6e77e-a3c8-4e1a-bcba-7134a9436051"
+    }, 
+    {
+        "highlighted": false, 
+        "title": " Ministerio de Justicia y Derechos Humanos", 
+        "children": [], 
+        "name": "justicia", 
+        "id": "06b380f8-888f-43c0-8367-16a7ddf47d4f"
+    }, 
+    {
+        "highlighted": false, 
+        "title": " Ministerio del Interior, Obras P\u00fablicas y Vivienda ", 
+        "children": [], 
+        "name": "interior", 
+        "id": "c88b1ad1-f78d-4cf9-848d-d73ccae6cd8e"
+    }, 
+    {
+        "highlighted": false, 
+        "title": "Ministerio de Producci\u00f3n y Trabajo", 
+        "children": [
+            {
+                "highlighted": false, 
+                "title": "Secretar\u00eda de Gobierno de Agroindustria", 
+                "children": [], 
+                "name": "agroindustria", 
+                "id": "b10c94e2-ade1-4a97-8c4c-b1ada7878d60"
+            }, 
+            {
+                "highlighted": false, 
+                "title": "Secretar\u00eda de Gobierno de Trabajo y Empleo", 
+                "children": [], 
+                "name": "trabajo", 
+                "id": "f31cb5dc-79ab-442b-ac7f-87ef6ad7749d"
+            }, 
+            {
+                "highlighted": false, 
+                "title": "Secretar\u00eda de Transformaci\u00f3n Productiva", 
+                "children": [], 
+                "name": "siep", 
+                "id": "83a04686-747a-4ba3-a0b1-2f05c3196bc4"
+            }
+        ], 
+        "name": "produccion", 
+        "id": "1ca8d15b-31fc-4f09-ba09-53edd3d4cce6"
+    }, 
+    {
+        "highlighted": false, 
+        "title": "Ministerio de Relaciones Exteriores y Culto", 
+        "children": [], 
+        "name": "exterior", 
+        "id": "89cd05f2-11c1-4f1a-875f-b4faf97eb4d4"
+    }, 
+    {
+        "highlighted": false, 
+        "title": "Ministerio de Salud y Desarrollo Social", 
+        "children": [
+            {
+                "highlighted": false, 
+                "title": "Secretar\u00eda de Gobierno de Salud", 
+                "children": [], 
+                "name": "salud", 
+                "id": "dd1f2018-587f-43af-9b07-fc32f5ab588b"
+            }
+        ], 
+        "name": "desarrollo-social", 
+        "id": "ef25c735-2abb-4165-94a6-a6798a103603"
+    }, 
+    {
+        "highlighted": false, 
+        "title": " Ministerio de Seguridad", 
+        "children": [], 
+        "name": "seguridad", 
+        "id": "908fd413-5a22-40e7-9204-38ef380ae232"
+    }, 
+    {
+        "highlighted": false, 
+        "title": " Ministerio de Transporte", 
+        "children": [], 
+        "name": "transporte", 
+        "id": "71418928-10c4-4625-aeaf-69a4d73d00ed"
+    }, 
+    {
+        "highlighted": false, 
+        "title": "Sin organizaci\u00f3n asignada", 
+        "children": [], 
+        "name": "otros", 
+        "id": "109d53ef-3ed3-498e-97d0-a456698969f7"
+    }
+]
\ No newline at end of file
diff --git a/tests/test_federation.py b/tests/test_federation.py
index 58449e4..d2a0af6 100644
--- a/tests/test_federation.py
+++ b/tests/test_federation.py
@@ -5,6 +5,7 @@ from __future__ import unicode_literals
 import unittest
 import os
 import re
+import json
 
 try:
     from mock import patch, MagicMock
@@ -481,7 +482,49 @@ class PushCatalogThemesTestCase(FederationSuite):
 @patch('pydatajson.federation.RemoteCKAN', autospec=True)
 class OrganizationsTestCase(FederationSuite):
 
+    def setUp(self):
+        self.portal_url = 'portal_url'
+        self.apikey = 'apikey'
+        self.org_tree = json.load(open(
+            self.get_sample('organization_tree.json')))
+
+    def check_hierarchy(self, node, parent=None):
+        if not node['success']:
+            self.assertTrue('children' not in node)
+            return
+        if parent is None:
+            self.assertTrue('groups' not in node)
+        else:
+            self.assertDictEqual(node['groups'][0],
+                                 {'name': parent})
+
+        for child in node['children']:
+            self.check_hierarchy(child, parent=node['name'])
+
     def test_get_organization_calls_api_correctly(self, mock_portal):
-        get_organizations_from_ckan('portal_url')
+        get_organizations_from_ckan(self.portal_url)
         mock_portal.return_value.call_action.assert_called_with(
             'group_tree', data_dict={'type': 'organization'})
+
+    def test_push_organizations_sends_correct_hierarchy(self, mock_portal):
+        mock_portal.return_value.call_action = (lambda _, data_dict: data_dict)
+        pushed_tree = push_organization_tree_to_ckan(self.portal_url,
+                                                     self.apikey,
+                                                     self.org_tree)
+        for node in pushed_tree:
+            self.check_hierarchy(node)
+
+    def test_push_organizations_cuts_trees_on_failures(self, mock_portal):
+        def mock_org_create(_action, data_dict):
+            broken_orgs = ('acumar', 'modernizacion', 'hacienda')
+            if data_dict['name'] in broken_orgs:
+                raise Exception('broken org on each level')
+            else:
+                return data_dict
+
+        mock_portal.return_value.call_action = mock_org_create
+        pushed_tree = push_organization_tree_to_ckan(self.portal_url,
+                                                     self.apikey,
+                                                     self.org_tree)
+        for node in pushed_tree:
+            self.check_hierarchy(node)