Repository: datosgobar/pydatajson

Issue:
Función push theme_to_ckan()
**Contexto**

Por el momento, la función `push_dataset_to_ckan()` federa los datasets sin tener en cuenta los `themes` presentes en el catálogo de destino. La operación se concreta, pero cualquier `theme` del dataset que no esté presente en el catálogo es ignorado.

El objetivo es implementar una función `push_theme_to_ckan()`, que mediante la API, cree los grupos de CKAN indicados. Esta función será luego utilizada por si sola o en conjunto con los wrappers para generar los temas pertinentes de un catalogo.

Comments:

Patch:
diff --git a/docs/MANUAL.md b/docs/MANUAL.md
index cd72809..914f87c 100644
--- a/docs/MANUAL.md
+++ b/docs/MANUAL.md
@@ -107,7 +107,7 @@ Toma los siguientes parámetros:
   mantener una consistencia más estricta dentro del catálogo a federar, es necesario validar los datos antes de pasarlos
   a la función. 
 
-- **pydatajson.DataJson.remove_dataset_from_ckan()**: Hace un borrado físico de un dataset en un portal de CKAN.
+- **pydatajson.federation.remove_dataset_from_ckan()**: Hace un borrado físico de un dataset en un portal de CKAN.
 Toma los siguientes parámetros:
     - **portal_url**: La URL del portal CKAN. Debe implementar la funcionalidad de `/data.json`.
     - **apikey**: La apikey de un usuario con los permisos que le permitan borrar el dataset.
@@ -121,6 +121,16 @@ Toma los siguientes parámetros:
     En caso de pasar más de un parámetro opcional, la función `remove_dataset_from_ckan()` borra aquellos datasets que
     cumplan con todas las condiciones.
 
+- **pydatajson.DataJson.push_theme_to_ckan()**: Crea un tema en el portal de destino
+Toma los siguientes parámetros:
+    - **portal_url**: La URL del portal CKAN. Debe implementar la funcionalidad de `/data.json`.
+    - **apikey**: La apikey de un usuario con los permisos que le permitan borrar el dataset.
+    - **identifier** (opcional, default: None): Id del `theme` que se quiere federar, en el catálogo de origen.
+    - **label** (opcional, default: None): label del `theme` que se quiere federar, en el catálogo de origen.
+
+    Debe pasarse por lo menos uno de los 2 parámetros opcionales. En caso de que se provean los 2, se prioriza el
+    identifier sobre el label.
+
 ## Uso
     
 ### Setup
diff --git a/pydatajson/ckan_utils.py b/pydatajson/ckan_utils.py
index 9724f44..9f68692 100644
--- a/pydatajson/ckan_utils.py
+++ b/pydatajson/ckan_utils.py
@@ -2,7 +2,6 @@
 # -*- coding: utf-8 -*-
 import json
 import re
-import sys
 from datetime import time
 from dateutil import parser, tz
 from .helpers import title_to_name
@@ -109,10 +108,21 @@ def map_distributions_to_resources(distributions, catalog_id=None):
         resource['mimetype'] = distribution.get('mediaType')
         resource['size'] = distribution.get('byteSize')
         resource['accessURL'] = distribution.get('accessURL')
-        resource['fileName'] = distribution.get('fileName')
+        fileName = distribution.get('fileName')
+        if fileName:
+            resource['fileName'] = fileName
         dist_fields = distribution.get('field')
         if dist_fields:
             resource['attributesDescription'] = json.dumps(dist_fields)
         resources.append(resource)
 
     return resources
+
+
+def map_theme_to_group(theme):
+
+    return {
+        "name": title_to_name(theme.get('id') or theme['label']),
+        "title": theme.get('label'),
+        "description": theme.get('description'),
+    }
diff --git a/pydatajson/federation.py b/pydatajson/federation.py
index 9573040..9f132cd 100644
--- a/pydatajson/federation.py
+++ b/pydatajson/federation.py
@@ -5,7 +5,7 @@
 from __future__ import print_function
 from ckanapi import RemoteCKAN
 from ckanapi.errors import NotFound
-from .ckan_utils import map_dataset_to_package
+from .ckan_utils import map_dataset_to_package, map_theme_to_group
 from .search import get_datasets
 
 
@@ -23,7 +23,6 @@ def push_dataset_to_ckan(catalog, owner_org, dataset_origin_identifier, portal_u
             demote_superThemes(bool): Si está en true, los ids de los super themes del dataset, se propagan como grupo.
             demote_themes(bool): Si está en true, los labels de los themes del dataset, pasan a ser tags. Sino,
             se pasan como grupo.
-
         Returns:
             str: El id del dataset en el catálogo de destino.
     """
@@ -103,3 +102,22 @@ def remove_datasets_from_ckan(portal_url, apikey, filter_in=None, filter_out=Non
 
     for identifier in identifiers:
         ckan_portal.call_action('dataset_purge', data_dict={'id': identifier})
+
+
+def push_theme_to_ckan(catalog, portal_url, apikey, identifier=None, label=None):
+    """Escribe la metadata de un theme en el portal pasado por parámetro.
+
+            Args:
+                catalog (DataJson): El catálogo de origen que contiene el theme.
+                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.
+                identifier (str): El identificador para buscar el theme en la taxonomia.
+                label (str): El label para buscar el theme en la taxonomia.
+            Returns:
+                str: El name del theme en el catálogo de destino.
+        """
+    ckan_portal = RemoteCKAN(portal_url, apikey=apikey)
+    theme = catalog.get_theme(identifier=identifier, label=label)
+    group = map_theme_to_group(theme)
+    pushed_group = ckan_portal.call_action('group_create', data_dict=group)
+    return pushed_group['name']


Test patch:
diff --git a/tests/test_ckan_utils.py b/tests/test_ckan_utils.py
index f90406e..83a7697 100644
--- a/tests/test_ckan_utils.py
+++ b/tests/test_ckan_utils.py
@@ -2,12 +2,8 @@
 
 import unittest
 import os
-import json
-import re
-import sys
-from dateutil import parser, tz
 from .context import pydatajson
-from pydatajson.ckan_utils import map_dataset_to_package, map_distributions_to_resources, convert_iso_string_to_utc
+from pydatajson.ckan_utils import *
 from pydatajson.helpers import title_to_name
 SAMPLES_DIR = os.path.join("tests", "samples")
 
@@ -216,6 +212,57 @@ class DatasetConversionTestCase(unittest.TestCase):
                 self.assertIsNone(resource.get('attributesDescription'))
 
 
+class ThemeConversionTests(unittest.TestCase):
+
+    @classmethod
+    def get_sample(cls, sample_filename):
+        return os.path.join(SAMPLES_DIR, sample_filename)
+
+    @classmethod
+    def setUpClass(cls):
+        catalog = pydatajson.DataJson(cls.get_sample('full_data.json'))
+        cls.theme = catalog.get_theme(identifier='adjudicaciones')
+
+    def test_all_attributes_are_replicated_if_present(self):
+        group = map_theme_to_group(self.theme)
+        self.assertEqual('adjudicaciones', group['name'])
+        self.assertEqual('Adjudicaciones', group['title'])
+        self.assertEqual('Datasets sobre licitaciones adjudicadas.', group['description'])
+
+    def test_label_is_used_as_name_if_id_not_present(self):
+        missing_id = dict(self.theme)
+        missing_id['label'] = u'#Will be used as name#'
+        missing_id.pop('id')
+        group = map_theme_to_group(missing_id)
+        self.assertEqual('will-be-used-as-name', group['name'])
+        self.assertEqual('#Will be used as name#', group['title'])
+        self.assertEqual('Datasets sobre licitaciones adjudicadas.', group['description'])
+
+    def test_theme_missing_label(self):
+        missing_label = dict(self.theme)
+        missing_label.pop('label')
+        group = map_theme_to_group(missing_label)
+        self.assertEqual('adjudicaciones', group['name'])
+        self.assertIsNone(group.get('title'))
+        self.assertEqual('Datasets sobre licitaciones adjudicadas.', group['description'])
+
+    def test_theme_missing_description(self):
+        missing_description = dict(self.theme)
+        missing_description.pop('description')
+        group = map_theme_to_group(missing_description)
+        self.assertEqual('adjudicaciones', group['name'])
+        self.assertEqual('Adjudicaciones', group['title'])
+        self.assertIsNone(group['description'])
+
+    def test_id_special_characters_are_removed(self):
+        special_char_id = dict(self.theme)
+        special_char_id['id'] = u'#Théme& $id?'
+        group = map_theme_to_group(special_char_id)
+        self.assertEqual('theme-id', group['name'])
+        self.assertEqual('Adjudicaciones', group['title'])
+        self.assertEqual('Datasets sobre licitaciones adjudicadas.', group['description'])
+
+
 class DatetimeConversionTests(unittest.TestCase):
 
     def test_timezones_are_handled_correctly(self):
diff --git a/tests/test_federation.py b/tests/test_federation.py
index e6804b9..fd8284e 100644
--- a/tests/test_federation.py
+++ b/tests/test_federation.py
@@ -3,14 +3,14 @@
 import unittest
 import os
 import re
-import sys
+
 try:
     from mock import patch, MagicMock
 except ImportError:
     from unittest.mock import patch, MagicMock
 
 from .context import pydatajson
-from pydatajson.federation import push_dataset_to_ckan, remove_datasets_from_ckan
+from pydatajson.federation import push_dataset_to_ckan, remove_datasets_from_ckan, push_theme_to_ckan
 from ckanapi.errors import NotFound
 
 SAMPLES_DIR = os.path.join("tests", "samples")
@@ -215,3 +215,40 @@ class RemoveDatasetTestCase(unittest.TestCase):
             'portal', 'key', only_time_series=True, organization='some_org')
         mock_portal.return_value.call_action.assert_called_with(
             'dataset_purge', data_dict={'id': 'id_2'})
+
+
+class PushThemeTestCase(unittest.TestCase):
+
+    @classmethod
+    def get_sample(cls, sample_filename):
+        return os.path.join(SAMPLES_DIR, sample_filename)
+
+    @classmethod
+    def setUpClass(cls):
+        cls.catalog = pydatajson.DataJson(cls.get_sample('full_data.json'))
+
+    @patch('pydatajson.federation.RemoteCKAN', autospec=True)
+    def test_empty_theme_search_raises_exception(self, mock_portal):
+        with self.assertRaises(AssertionError):
+            push_theme_to_ckan(self.catalog, 'portal_url', 'apikey')
+
+    @patch('pydatajson.federation.RemoteCKAN', autospec=True)
+    def test_function_pushes_theme_by_identifier(self, mock_portal):
+        mock_portal.return_value.call_action = MagicMock(return_value={'name': 'group_name'})
+        result = push_theme_to_ckan(self.catalog, 'portal_url', 'apikey', identifier='compras')
+        self.assertEqual('group_name', result)
+
+    @patch('pydatajson.federation.RemoteCKAN', autospec=True)
+    def test_function_pushes_theme_by_label(self, mock_portal):
+        mock_portal.return_value.call_action = MagicMock(return_value={'name': 'other_name'})
+        result = push_theme_to_ckan(self.catalog, 'portal_url', 'apikey', label='Adjudicaciones')
+        self.assertEqual('other_name', result)
+
+    @patch('pydatajson.federation.RemoteCKAN', autospec=True)
+    def test_ckan_portal_is_called_with_correct_parametres(self, mock_portal):
+        mock_portal.return_value.call_action = MagicMock(return_value={'name': u'contrataciones'})
+        group = {'name': u'contrataciones',
+                 'title': u'Contrataciones',
+                 'description': u'Datasets sobre contrataciones.'}
+        push_theme_to_ckan(self.catalog, 'portal_url', 'apikey', identifier='contrataciones')
+        mock_portal.return_value.call_action.assert_called_once_with('group_create', data_dict=group)