Repository: CartoDB/cartoframes
Pull request #1087 (2019-10-10 11:22:12)

1079 integrate subscriptions
Solves https://github.com/CartoDB/cartoframes/issues/1079

- [x] Add Catalog().subscriptions method
- [x] Create Subscriptions class
- [x] Create SubscriptionInfo class
- [x] Add Dataset.get(id).subscribe method
- [x] Add Dataset.get(id).subscription_info method
- [x] Add Geography.get(id).subscribe method
- [x] Add Geography.get(id).subscription_info method
- [x] Add/Update tests
- [x] Add subscription form (for the notebook)
- [x] Move `fetch_subscriptions` to `observatory/utils`
- [x] Connect `GET /do/subscriptions` form carto-python
- [x] Connect `POST /do/subscriptions` form carto-python
- [x] Connect `GET /do/subscription_info` form carto-python

![](https://user-images.githubusercontent.com/2227572/66580177-45be0b00-eb7e-11e9-80e8-d8d89bfab520.png)




Patch:
diff --git a/cartoframes/data/observatory/catalog.py b/cartoframes/data/observatory/catalog.py
index a07b1c7a..4553f12b 100644
--- a/cartoframes/data/observatory/catalog.py
+++ b/cartoframes/data/observatory/catalog.py
@@ -1,12 +1,15 @@
 from __future__ import absolute_import
 
+from .dataset import CatalogDataset
 from .entity import is_slug_value
 from .category import Category
 from .country import Country
 from .geography import Geography
-from .dataset import CatalogDataset
+from .subscriptions import Subscriptions
 from .repository.constants import COUNTRY_FILTER, CATEGORY_FILTER, GEOGRAPHY_FILTER
 
+from ...auth import Credentials, defaults
+
 
 class Catalog(object):
     """Data Observatory Catalog"""
@@ -114,18 +117,27 @@ class Catalog(object):
 
         self.filters = {}
 
-    def purchased_datasets(self, credentials=None):
-        """Get all the datasets in the Catalog
+    def subscriptions(self, credentials=None):
+        """Get all the subscriptions in the Catalog
 
         Args:
             credentials (:py:class:`Credentials <cartoframes.auth.Credentials>`, optional):
-                A :py:class:`Credentials <cartoframes.auth.Credentials>`
-                instance can be used in place of a `username`|`base_url` / `api_key` combination.
-                Only required for the purchased datasets.
+                credentials of CARTO user account. If not provided,
+                a default credentials (if set with :py:meth:`set_default_credentials
+                <cartoframes.auth.set_default_credentials>`) will be used.
 
         Returns:
             :py:class:`CatalogDatasets <cartoframes.data.observatory.CatalogDatasets>`
 
         """
 
-        return CatalogDataset.get_all(self.filters, credentials)
+        _no_filters = {}
+        _credentials = credentials or defaults.get_default_credentials()
+
+        if not isinstance(_credentials, Credentials):
+            raise ValueError('`credentials` must be a Credentials class instance')
+
+        return Subscriptions(
+            CatalogDataset.get_all(_no_filters, _credentials),
+            Geography.get_all(_no_filters, _credentials)
+        )
diff --git a/cartoframes/data/observatory/dataset.py b/cartoframes/data/observatory/dataset.py
index e23a5dbb..93ce7b78 100644
--- a/cartoframes/data/observatory/dataset.py
+++ b/cartoframes/data/observatory/dataset.py
@@ -6,6 +6,11 @@ from .repository.dataset_repo import get_dataset_repo
 from .repository.variable_repo import get_variable_repo
 from .repository.variable_group_repo import get_variable_group_repo
 from .repository.constants import DATASET_FILTER
+from . import subscription_info
+from . import subscriptions
+from . import utils
+
+DATASET_TYPE = 'dataset'
 
 
 class CatalogDataset(CatalogEntity):
@@ -84,10 +89,42 @@ class CatalogDataset(CatalogEntity):
 
         Args:
             credentials (:py:class:`Credentials <cartoframes.auth.Credentials>`, optional):
-              credentials of CARTO user account. If not provided,
-              a default credentials (if set with :py:meth:`set_default_credentials
-              <cartoframes.auth.set_default_credentials>`) will be attempted to be
-              used.
+                credentials of CARTO user account. If not provided,
+                a default credentials (if set with :py:meth:`set_default_credentials
+                <cartoframes.auth.set_default_credentials>`) will be used.
         """
 
         return self._download(credentials)
+
+    def subscribe(self, credentials=None):
+        """Subscribe to a Dataset.
+
+        Args:
+            credentials (:py:class:`Credentials <cartoframes.auth.Credentials>`, optional):
+                credentials of CARTO user account. If not provided,
+                a default credentials (if set with :py:meth:`set_default_credentials
+                <cartoframes.auth.set_default_credentials>`) will be used.
+        """
+
+        _credentials = self._get_credentials(credentials)
+        _subscribed_ids = subscriptions.get_subscription_ids(_credentials)
+
+        if self.id in _subscribed_ids:
+            utils.display_existing_subscription_message(self.id, DATASET_TYPE)
+        else:
+            utils.display_subscription_form(self.id, DATASET_TYPE, _credentials)
+
+    def subscription_info(self, credentials=None):
+        """Get the subscription information of a Dataset.
+
+        Args:
+            credentials (:py:class:`Credentials <cartoframes.auth.Credentials>`, optional):
+                credentials of CARTO user account. If not provided,
+                a default credentials (if set with :py:meth:`set_default_credentials
+                <cartoframes.auth.set_default_credentials>`) will be used.
+        """
+
+        _credentials = self._get_credentials(credentials)
+
+        return subscription_info.SubscriptionInfo(
+            subscription_info.fetch_subscription_info(self.id, DATASET_TYPE, _credentials))
diff --git a/cartoframes/data/observatory/entity.py b/cartoframes/data/observatory/entity.py
index 4a64eed6..1eb5784b 100644
--- a/cartoframes/data/observatory/entity.py
+++ b/cartoframes/data/observatory/entity.py
@@ -6,7 +6,7 @@ from google.api_core.exceptions import NotFound
 from carto.exceptions import CartoException
 
 from ..clients.bigquery_client import BigQueryClient
-from ...auth import get_default_credentials
+from ...auth import Credentials, defaults
 
 try:
     from abc import ABC, abstractmethod
@@ -74,7 +74,7 @@ class CatalogEntity(ABC):
         return self.id
 
     def _download(self, credentials=None):
-        credentials = _get_credentials(credentials)
+        credentials = self._get_credentials(credentials)
         user_dataset = credentials.get_do_dataset()
         bq_client = _get_bigquery_client(_WORKING_PROJECT, credentials)
 
@@ -91,9 +91,13 @@ class CatalogEntity(ABC):
 
         return file_path
 
+    def _get_credentials(self, credentials=None):
+        _credentials = credentials or defaults.get_default_credentials()
 
-def _get_credentials(credentials=None):
-    return credentials or get_default_credentials()
+        if not isinstance(_credentials, Credentials):
+            raise ValueError('`credentials` must be a Credentials class instance')
+
+        return _credentials
 
 
 def _get_bigquery_client(project, credentials):
diff --git a/cartoframes/data/observatory/geography.py b/cartoframes/data/observatory/geography.py
index 8f1e5c11..42418444 100644
--- a/cartoframes/data/observatory/geography.py
+++ b/cartoframes/data/observatory/geography.py
@@ -5,6 +5,11 @@ from .entity import CatalogEntity
 from .repository.dataset_repo import get_dataset_repo
 from .repository.geography_repo import get_geography_repo
 from .repository.constants import GEOGRAPHY_FILTER
+from . import subscription_info
+from . import subscriptions
+from . import utils
+
+GEOGRAPHY_TYPE = 'geography'
 
 
 class Geography(CatalogEntity):
@@ -55,15 +60,51 @@ class Geography(CatalogEntity):
     def summary(self):
         return self.data['summary_jsonb']
 
+    @classmethod
+    def get_all(cls, filters=None, credentials=None):
+        return cls.entity_repo.get_all(filters, credentials)
+
     def download(self, credentials=None):
         """Download Geography data.
 
         Args:
             credentials (:py:class:`Credentials <cartoframes.auth.Credentials>`, optional):
-              credentials of CARTO user account. If not provided,
-              a default credentials (if set with :py:meth:`set_default_credentials
-              <cartoframes.auth.set_default_credentials>`) will attempted to be
-              used.
+                credentials of CARTO user account. If not provided,
+                a default credentials (if set with :py:meth:`set_default_credentials
+                <cartoframes.auth.set_default_credentials>`) will be used.
         """
 
         return self._download(credentials)
+
+    def subscribe(self, credentials=None):
+        """Subscribe to a Geography.
+
+        Args:
+            credentials (:py:class:`Credentials <cartoframes.auth.Credentials>`, optional):
+                credentials of CARTO user account. If not provided,
+                a default credentials (if set with :py:meth:`set_default_credentials
+                <cartoframes.auth.set_default_credentials>`) will be used.
+        """
+
+        _credentials = self._get_credentials(credentials)
+        _subscribed_ids = subscriptions.get_subscription_ids(_credentials)
+
+        if self.id in _subscribed_ids:
+            utils.display_existing_subscription_message(self.id, GEOGRAPHY_TYPE)
+        else:
+            utils.display_subscription_form(self.id, GEOGRAPHY_TYPE, _credentials)
+
+    def subscription_info(self, credentials=None):
+        """Get the subscription information of a Geography.
+
+        Args:
+            credentials (:py:class:`Credentials <cartoframes.auth.Credentials>`, optional):
+                credentials of CARTO user account. If not provided,
+                a default credentials (if set with :py:meth:`set_default_credentials
+                <cartoframes.auth.set_default_credentials>`) will be used.
+        """
+
+        _credentials = self._get_credentials(credentials)
+
+        return subscription_info.SubscriptionInfo(
+            subscription_info.fetch_subscription_info(self.id, GEOGRAPHY_TYPE, _credentials))
diff --git a/cartoframes/data/observatory/repository/dataset_repo.py b/cartoframes/data/observatory/repository/dataset_repo.py
index df92b68a..b6dde324 100644
--- a/cartoframes/data/observatory/repository/dataset_repo.py
+++ b/cartoframes/data/observatory/repository/dataset_repo.py
@@ -20,7 +20,9 @@ class DatasetRepository(EntityRepository):
 
     def get_all(self, filters=None, credentials=None):
         self.client.set_user_credentials(credentials)
-        return self._get_filtered_entities(filters)
+        response = self._get_filtered_entities(filters)
+        self.client.set_user_credentials(None)
+        return response
 
     @classmethod
     def _get_entity_class(cls):
diff --git a/cartoframes/data/observatory/repository/geography_repo.py b/cartoframes/data/observatory/repository/geography_repo.py
index fbf0065e..929ac551 100644
--- a/cartoframes/data/observatory/repository/geography_repo.py
+++ b/cartoframes/data/observatory/repository/geography_repo.py
@@ -18,6 +18,12 @@ class GeographyRepository(EntityRepository):
     def __init__(self):
         super(GeographyRepository, self).__init__(_GEOGRAPHY_ID_FIELD, _ALLOWED_FILTERS, _GEOGRAPHY_SLUG_FIELD)
 
+    def get_all(self, filters=None, credentials=None):
+        self.client.set_user_credentials(credentials)
+        response = self._get_filtered_entities(filters)
+        self.client.set_user_credentials(None)
+        return response
+
     @classmethod
     def _get_entity_class(cls):
         from cartoframes.data.observatory.geography import Geography
diff --git a/cartoframes/data/observatory/repository/repo_client.py b/cartoframes/data/observatory/repository/repo_client.py
index acf081d8..8e11bbb8 100644
--- a/cartoframes/data/observatory/repository/repo_client.py
+++ b/cartoframes/data/observatory/repository/repo_client.py
@@ -1,9 +1,8 @@
 from __future__ import absolute_import
 
-from carto.do_subscriptions import DOSubscriptionManager
-
 from ...clients import SQLClient
-from ....auth import Credentials, get_default_credentials
+from ....auth import Credentials
+from ..subscriptions import get_subscription_ids
 
 
 class RepoClient(object):
@@ -16,7 +15,7 @@ class RepoClient(object):
         self.client = SQLClient(self._do_credentials)
 
     def set_user_credentials(self, credentials):
-        self._user_credentials = credentials or get_default_credentials()
+        self._user_credentials = credentials
 
     def get_countries(self, filters=None):
         query = 'SELECT DISTINCT t.country_id AS id FROM datasets_public t'
@@ -44,7 +43,14 @@ class RepoClient(object):
 
     def get_geographies(self, filters=None):
         query = 'SELECT t.* FROM geographies_public t'
-        return self._run_query(query, filters)
+
+        extra_condition = []
+        if self._user_credentials is not None:
+            ids = get_subscription_ids(self._user_credentials)
+            if len(ids) > 0:
+                extra_condition.append('t.id IN ({})'.format(ids))
+
+        return self._run_query(query, filters, extra_condition)
 
     def get_geographies_joined_datasets(self, filters=None):
         query = 'SELECT DISTINCT g.* FROM geographies_public g, datasets_public t'
@@ -55,7 +61,9 @@ class RepoClient(object):
 
         extra_condition = []
         if self._user_credentials is not None:
-            extra_condition.append('t.id IN ({})'.format(self._get_purchased_dataset_ids()))
+            ids = get_subscription_ids(self._user_credentials)
+            if len(ids) > 0:
+                extra_condition.append('t.id IN ({})'.format(ids))
 
         return self._run_query(query, filters, extra_condition)
 
@@ -84,18 +92,6 @@ class RepoClient(object):
 
         return "t.{} = '{}'".format(key, value)
 
-    def _get_purchased_dataset_ids(self):
-        purchased_datasets = self._fetch_purchased_datasets()
-        purchased_dataset_ids = list(map(lambda pd: pd.id, purchased_datasets))
-        return ','.join(["'" + id + "'" for id in purchased_dataset_ids])
-
-    def _fetch_purchased_datasets(self):
-        api_key_auth_client = self._user_credentials.get_api_key_auth_client()
-        do_manager = DOSubscriptionManager(api_key_auth_client)
-        if do_manager is not None:
-            return do_manager.all()
-        return []
-
     def __new__(cls):
         if not RepoClient.__instance:
             RepoClient.__instance = object.__new__(cls)
diff --git a/cartoframes/data/observatory/subscription_info.py b/cartoframes/data/observatory/subscription_info.py
new file mode 100644
index 00000000..29c87772
--- /dev/null
+++ b/cartoframes/data/observatory/subscription_info.py
@@ -0,0 +1,55 @@
+
+from carto.do_subscription_info import DOSubscriptionInfoManager
+
+
+class SubscriptionInfo(object):
+
+    def __init__(self, raw_data):
+        self._raw_data = raw_data
+
+    def __repr__(self):
+        props = ['id', 'estimated_delivery_days', 'subscription_list_price',
+                 'tos', 'tos_link', 'licenses', 'licenses_link', 'rights']
+        return 'Properties: {}'.format(', '.join(props))
+
+    @property
+    def id(self):
+        return self._raw_data.get('id')
+
+    @property
+    def estimated_delivery_days(self):
+        return self._raw_data.get('estimated_delivery_days')
+
+    @property
+    def subscription_list_price(self):
+        return self._raw_data.get('subscription_list_price')
+
+    @property
+    def tos(self):
+        return self._raw_data.get('tos')
+
+    @property
+    def tos_link(self):
+        return self._raw_data.get('tos_link')
+
+    @property
+    def licenses(self):
+        return self._raw_data.get('licenses')
+
+    @property
+    def licenses_link(self):
+        return self._raw_data.get('licenses_link')
+
+    @property
+    def rights(self):
+        return self._raw_data.get('rights')
+
+
+def fetch_subscription_info(id, type, credentials):
+    api_key_auth_client = credentials.get_api_key_auth_client()
+    do_manager = DOSubscriptionInfoManager(api_key_auth_client)
+    return _resource_to_dict(do_manager.get(id, type))
+
+
+def _resource_to_dict(resource):
+    return {field: getattr(resource, field) for field in resource.fields}
diff --git a/cartoframes/data/observatory/subscriptions.py b/cartoframes/data/observatory/subscriptions.py
new file mode 100644
index 00000000..63562ed1
--- /dev/null
+++ b/cartoframes/data/observatory/subscriptions.py
@@ -0,0 +1,38 @@
+
+from carto.do_subscriptions import DOSubscriptionManager, DOSubscriptionCreationManager
+
+
+class Subscriptions(object):
+
+    def __init__(self, datasets, geographies):
+        self._subscriptions_datasets = datasets
+        self._subscriptions_geographies = geographies
+
+    @property
+    def datasets(self):
+        return self._subscriptions_datasets
+
+    @property
+    def geographies(self):
+        return self._subscriptions_geographies
+
+
+def get_subscription_ids(credentials):
+    subscriptions = fetch_subscriptions(credentials)
+    subscriptions_ids = list(map(lambda pd: pd.id, subscriptions))
+    return ','.join(["'" + id + "'" for id in subscriptions_ids])
+
+
+def fetch_subscriptions(credentials):
+    if credentials:
+        api_key_auth_client = credentials.get_api_key_auth_client()
+        do_manager = DOSubscriptionManager(api_key_auth_client)
+        if do_manager is not None:
+            return do_manager.all()
+    return []
+
+
+def trigger_subscription(id, type, credentials):
+    api_key_auth_client = credentials.get_api_key_auth_client()
+    do_manager = DOSubscriptionCreationManager(api_key_auth_client)
+    return do_manager.create(id=id, type=type)
diff --git a/cartoframes/data/observatory/utils.py b/cartoframes/data/observatory/utils.py
new file mode 100644
index 00000000..cf6a7b0d
--- /dev/null
+++ b/cartoframes/data/observatory/utils.py
@@ -0,0 +1,112 @@
+from __future__ import absolute_import
+
+from .subscriptions import trigger_subscription
+from .subscription_info import fetch_subscription_info
+
+
+def is_ipython_notebook():
+    """
+    Detect whether we are in a Jupyter notebook.
+    """
+    try:
+        cfg = get_ipython().config
+        if 'IPKernelApp' in cfg:
+            return True
+        else:
+            return False
+    except NameError:
+        return False
+
+
+if is_ipython_notebook():
+    from IPython.display import display
+    from ipywidgets.widgets import HTML, Layout, Button, GridspecLayout
+
+
+def display_existing_subscription_message(id, type):
+    if is_ipython_notebook():
+        message = '''
+        <h3>Subscription already purchased</h3>
+        The {0} <b>{1}</b> has already been purchased.
+        '''.format(type, id)
+        text = HTML(message)
+        display(text)
+
+
+def display_subscription_form(id, type, credentials):
+    info = fetch_subscription_info(id, type, credentials)
+
+    if info.get('type') != type:
+        raise Exception('Incorrect type returned.')
+
+    if info.get('subscription_list_price') is None:
+        raise Exception('This {} has incomplete information. Please contact to support@carto.com.'.format(type))
+
+    if is_ipython_notebook():
+        display_subscription_form_notebook(id, info, credentials)
+    else:
+        display_subscription_form_cli()
+
+
+def display_subscription_form_notebook(id, info, credentials):
+    message = '''
+    <h3>Subscription contract</h3>
+    You are about to subscribe to <b>{id}</b>.
+    The cost of this {type} is <b>${subscription_list_price}</b>.
+    If you want to proceed, a Request will be sent to CARTO who will
+    order the data and load it into your account.
+    This {type} is available for Instant Order for your organization,
+    so it will automatically process the order and you will get immediate access to the {type}.
+    In order to proceed we need you to agree to the License of the {type}
+    available at <b><a href="{tos_link}" target="_blank">this link</a></b>.
+    <br>Do you want to proceed?
+    '''.format(**info)
+
+    ok_response = '''
+    <b>Congrats!</b><br>The {type} <b>{id}</b> has been requested and it will be available in your account soon.
+    '''.format(**info)
+    cancel_message = '''
+    The {type} <b>{id}</b> has not been purchased.
+    '''.format(**info)
+
+    text, buttons = _create_notebook_form(id, info.get('type'), message, ok_response, cancel_message, credentials)
+
+    display(text, buttons)
+
+
+def _create_notebook_form(id, type, message, ok_response, cancel_message, credentials):
+    text = HTML(message)
+
+    button_yes = Button(
+        description='Yes', button_style='info', layout=Layout(height='32px', width='176px'))
+    button_no = Button(
+        description='No', button_style='', layout=Layout(height='32px', width='176px'))
+
+    buttons = GridspecLayout(1, 5)
+    buttons[0, 0] = button_yes
+    buttons[0, 1] = button_no
+
+    def disable_buttons():
+        button_yes.disabled = True
+        button_no.disabled = True
+
+    def on_button_yes_clicked(b):
+        disable_buttons()
+        response = trigger_subscription(id, type, credentials)
+        if response:
+            display(HTML(ok_response))
+        else:
+            display(HTML('Error'))
+
+    def on_button_no_clicked(b):
+        disable_buttons()
+        display(HTML(cancel_message))
+
+    button_yes.on_click(on_button_yes_clicked)
+    button_no.on_click(on_button_no_clicked)
+
+    return (text, buttons)
+
+
+def display_subscription_form_cli():
+    print('This method is not yet implemented in CLI')
diff --git a/examples/_debug/subscriptions.ipynb b/examples/_debug/subscriptions.ipynb
new file mode 100644
index 00000000..db185e2b
--- /dev/null
+++ b/examples/_debug/subscriptions.ipynb
@@ -0,0 +1,123 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from cartoframes.auth import Credentials, set_default_credentials\n",
+    "from cartoframes.data.observatory import Catalog, CatalogDataset, Geography"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "Catalog().datasets, Catalog().geographies"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "credentials = Credentials.from_file()\n",
+    "set_default_credentials(credentials)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dataset = CatalogDataset.get('ags_climateandw_cffac915')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "info = dataset.subscription_info()\n",
+    "info.subscription_list_price"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dataset.subscribe()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "geography = Geography.get('ags_q17_4739be4f')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "info = geography.subscription_info()\n",
+    "info.subscription_list_price"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "scrolled": true
+   },
+   "outputs": [],
+   "source": [
+    "geography.subscribe(credentials)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "subscriptions = Catalog().subscriptions()\n",
+    "\n",
+    "subscriptions.datasets, subscriptions.geographies"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.6.7"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/examples/_debug/ui-form.ipynb b/examples/_debug/ui-form.ipynb
new file mode 100644
index 00000000..65710d66
--- /dev/null
+++ b/examples/_debug/ui-form.ipynb
@@ -0,0 +1,163 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "message = '''\n",
+    "<h3>Subscription contract</h3>\n",
+    "You are about to subscribe to <b>%{dataset_name}</b>. The cost of this dataset is <b>$%{price}/%{update_frequency}</b>. If you want to proceed, a Request will be sent to CARTO who will order the data and load it into your account. This dataset is available for Instant Order for your organization, so it will automatically process the order and you will get inmediate access to the dataset. In order to proceed we need you to agree to the License of the dataset available at http://adasdasd.<br>Do you want to proceed?\n",
+    "'''"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "application/vnd.jupyter.widget-view+json": {
+       "model_id": "85ff89cd96bf472cbf5255f0aa5ac59e",
+       "version_major": 2,
+       "version_minor": 0
+      },
+      "text/plain": [
+       "HTML(value='\\n<h3>Subscription contract</h3>\\nYou are about to subscribe to <b>%{dataset_name}</b>. The cost o…"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    },
+    {
+     "data": {
+      "application/vnd.jupyter.widget-view+json": {
+       "model_id": "46c18dfd7228439cbe86474dc4cdc579",
+       "version_major": 2,
+       "version_minor": 0
+      },
+      "text/plain": [
+       "GridspecLayout(children=(Button(button_style='info', description='Yes', layout=Layout(grid_area='widget001', h…"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "from ipywidgets.widgets import HTML, GridspecLayout, Button, Layout\n",
+    "from IPython.display import display\n",
+    "\n",
+    "def create_expanded_button(description, button_style):\n",
+    "    return Button(description=description, button_style=button_style, layout=Layout(height='32px', width='176px'))\n",
+    "\n",
+    "text = HTML(message)\n",
+    "\n",
+    "button_yes = create_expanded_button('Yes', 'info')\n",
+    "button_no = create_expanded_button('No', '')\n",
+    "\n",
+    "buttons = GridspecLayout(1, 5)\n",
+    "buttons[0, 0] = button_yes\n",
+    "buttons[0, 1] = button_no\n",
+    "\n",
+    "def disable_buttons():\n",
+    "    button_yes.disabled = True\n",
+    "    button_no.disabled = True\n",
+    "\n",
+    "def on_button_yes_clicked(b):\n",
+    "    disable_buttons()\n",
+    "    display(HTML('Yes'))\n",
+    "    \n",
+    "def on_button_no_clicked(b):\n",
+    "    disable_buttons()\n",
+    "    display(HTML('No'))\n",
+    "    \n",
+    "button_yes.on_click(on_button_yes_clicked)\n",
+    "button_no.on_click(on_button_no_clicked)\n",
+    "\n",
+    "display(text, buttons)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from cartoframes.auth import Credentials\n",
+    "from cartoframes.data.observatory import Dataset, Catalog"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dataset = Dataset.get('carto-do-public-data.acs.demographics_acs_usa_cbsaclipped_2015_5yrs_20132017')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 10,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "application/vnd.jupyter.widget-view+json": {
+       "model_id": "04b75014ebc541b88947516108d17b5d",
+       "version_major": 2,
+       "version_minor": 0
+      },
+      "text/plain": [
+       "HTML(value='\\n<h3>Subscription contract</h3>\\nYou are about to subscribe to <b>%{dataset_name}</b>. The cost o…"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    },
+    {
+     "data": {
+      "application/vnd.jupyter.widget-view+json": {
+       "model_id": "f0021b1f92a34ffd81af1a3f0562563e",
+       "version_major": 2,
+       "version_minor": 0
+      },
+      "text/plain": [
+       "GridspecLayout(children=(Button(button_style='info', description='Yes', layout=Layout(grid_area='widget001', h…"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "credentials = Credentials('arroyo-carto', '26b674ea4f1f5e751ccb9559e4bb4ac4f9e65126')\n",
+    "dataset.subscribe(credentials)"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.6.7"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/requirements.txt b/requirements.txt
index 575cec71..9c558e35 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,1 @@
 .
--e git+https://github.com/CartoDB/carto-python.git#egg=carto
diff --git a/setup.py b/setup.py
index 209c08b6..af0fdf6e 100644
--- a/setup.py
+++ b/setup.py
@@ -20,7 +20,7 @@ def walk_subpkg(name):
 
 REQUIRES = [
     'appdirs>=1.4.3,<2.0',
-    'carto>=1.7.0,<2.0',
+    'carto>=1.8.0,<2.0',
     'jinja2>=2.10.1,<3.0',
     'geopandas>=0.6.0,<1.0',
     'tqdm>=4.32.1,<5.0',


Test patch:
diff --git a/test/data/observatory/examples.py b/test/data/observatory/examples.py
index 56206aae..edecbc74 100644
--- a/test/data/observatory/examples.py
+++ b/test/data/observatory/examples.py
@@ -156,3 +156,14 @@ db_variable_group2 = {
 test_variable_group1 = VariableGroup(db_variable_group1)
 test_variable_group2 = VariableGroup(db_variable_group2)
 test_variables_groups = CatalogList([test_variable_group1, test_variable_group2])
+
+test_subscription_info = {
+    'id': 'id',
+    'estimated_delivery_days': 0,
+    'subscription_list_price': 100,
+    'tos': 'tos',
+    'tos_link': 'tos_link',
+    'licenses': 'licenses',
+    'licenses_link': 'licenses_link',
+    'rights': 'rights'
+}
diff --git a/test/data/observatory/repository/test_dataset_repo.py b/test/data/observatory/repository/test_dataset_repo.py
index 2b491d50..652efb81 100644
--- a/test/data/observatory/repository/test_dataset_repo.py
+++ b/test/data/observatory/repository/test_dataset_repo.py
@@ -1,18 +1,18 @@
 import unittest
 
 from cartoframes.auth import Credentials
-from cartoframes.data.observatory.dataset import CatalogDataset
 
 from cartoframes.exceptions import DiscoveryException
+from cartoframes.data.observatory.dataset import CatalogDataset
 from cartoframes.data.observatory.entity import CatalogList
 from cartoframes.data.observatory.repository.dataset_repo import DatasetRepository
 from cartoframes.data.observatory.repository.repo_client import RepoClient
 from ..examples import test_dataset1, test_datasets, db_dataset1, db_dataset2
 
 try:
-    from unittest.mock import Mock, patch
+    from unittest.mock import Mock, patch, call
 except ImportError:
-    from mock import Mock, patch
+    from mock import Mock, patch, call
 
 
 class TestDatasetRepo(unittest.TestCase):
@@ -43,7 +43,7 @@ class TestDatasetRepo(unittest.TestCase):
         datasets = repo.get_all(credentials=credentials)
 
         # Then
-        mocked_set_user_credentials.assert_called_once_with(credentials)
+        mocked_set_user_credentials.assert_has_calls([call(credentials), call(None)])
         mocked_get_datasets.assert_called_once_with(None)
         assert isinstance(datasets, CatalogList)
         assert datasets == test_datasets
@@ -97,6 +97,7 @@ class TestDatasetRepo(unittest.TestCase):
         repo = DatasetRepository()
 
         # When
+        print(requested_id)
         dataset = repo.get_by_id(requested_id)
 
         # Then
diff --git a/test/data/observatory/repository/test_geography_repo.py b/test/data/observatory/repository/test_geography_repo.py
index d360bfc4..98ba8869 100644
--- a/test/data/observatory/repository/test_geography_repo.py
+++ b/test/data/observatory/repository/test_geography_repo.py
@@ -1,5 +1,6 @@
 import unittest
 
+from cartoframes.auth import Credentials
 from cartoframes.exceptions import DiscoveryException
 from cartoframes.data.observatory.entity import CatalogList
 from cartoframes.data.observatory.geography import Geography
@@ -8,9 +9,9 @@ from cartoframes.data.observatory.repository.repo_client import RepoClient
 from ..examples import test_geography1, test_geographies, db_geography1, db_geography2
 
 try:
-    from unittest.mock import Mock, patch
+    from unittest.mock import Mock, patch, call
 except ImportError:
-    from mock import Mock, patch
+    from mock import Mock, patch, call
 
 
 class TestGeographyRepo(unittest.TestCase):
@@ -29,6 +30,23 @@ class TestGeographyRepo(unittest.TestCase):
         assert isinstance(geographies, CatalogList)
         assert geographies == test_geographies
 
+    @patch.object(RepoClient, 'get_geographies')
+    @patch.object(RepoClient, 'set_user_credentials')
+    def test_get_all_credentials(self, mocked_set_user_credentials, mocked_get_geographies):
+        # Given
+        mocked_get_geographies.return_value = [db_geography1, db_geography2]
+        credentials = Credentials('user', '1234')
+        repo = GeographyRepository()
+
+        # When
+        geographies = repo.get_all(credentials=credentials)
+
+        # Then
+        mocked_set_user_credentials.assert_has_calls([call(credentials), call(None)])
+        mocked_get_geographies.assert_called_once_with(None)
+        assert isinstance(geographies, CatalogList)
+        assert geographies == test_geographies
+
     @patch.object(RepoClient, 'get_geographies')
     def test_get_all_when_empty(self, mocked_repo):
         # Given
diff --git a/test/data/observatory/repository/test_repo_client.py b/test/data/observatory/repository/test_repo_client.py
index 27bcae0b..a1fff38c 100644
--- a/test/data/observatory/repository/test_repo_client.py
+++ b/test/data/observatory/repository/test_repo_client.py
@@ -38,13 +38,18 @@ class TestRepoClient(unittest.TestCase):
         filters = {
             'category_id': 'demographics',
             'country_id': 'usa'}
-        expected_query = "SELECT t.* FROM datasets t WHERE t.category_id = 'demographics' AND t.country_id = 'usa'"
+        expected_select = 'SELECT t.* FROM datasets t WHERE'
+        expected_filter_category = "t.category_id = 'demographics'"
+        expected_filter_country = "t.country_id = 'usa'"
 
         # When
         datasets = repo._run_query(query, filters)
 
         # Then
-        mocked_client.assert_called_once_with(expected_query)
+        actual_query = str(mocked_client.call_args_list)
+        assert expected_select in actual_query
+        assert expected_filter_category in actual_query
+        assert expected_filter_country in actual_query
         assert datasets == [db_dataset1, db_dataset2]
 
     @patch.object(SQLClient, 'query')
diff --git a/test/data/observatory/test_catalog.py b/test/data/observatory/test_catalog.py
index 5759baf2..f1eaba9e 100644
--- a/test/data/observatory/test_catalog.py
+++ b/test/data/observatory/test_catalog.py
@@ -1,15 +1,17 @@
+import pytest
 import unittest
 
-
 from cartoframes.auth import Credentials
+from cartoframes.data.observatory.dataset import CatalogDataset
 from cartoframes.data.observatory.geography import Geography
 from cartoframes.data.observatory.country import Country
 from cartoframes.data.observatory.category import Category
 from cartoframes.data.observatory.dataset import CatalogDataset
 from cartoframes.data.observatory.catalog import Catalog
+from cartoframes.data.observatory.subscriptions import Subscriptions
 from cartoframes.data.observatory.repository.geography_repo import GeographyRepository
 from .examples import test_country2, test_country1, test_category1, test_category2, test_dataset1, test_dataset2, \
-    test_geographies, test_datasets, test_categories, test_countries, test_geography1
+    test_geographies, test_datasets, test_categories, test_countries, test_geography1, test_geography2
 
 try:
     from unittest.mock import Mock, patch
@@ -146,16 +148,59 @@ class TestCatalog(unittest.TestCase):
         assert datasets == test_datasets
 
     @patch.object(CatalogDataset, 'get_all')
-    def test_purchased_datasets(self, mocked_purchased_datasets):
+    @patch.object(Geography, 'get_all')
+    def test_subscriptions(self, mocked_geographies, mocked_datasets):
         # Given
         expected_datasets = [test_dataset1, test_dataset2]
-        mocked_purchased_datasets.return_value = expected_datasets
+        expected_geographies = [test_geography1, test_geography2]
+        mocked_datasets.return_value = expected_datasets
+        mocked_geographies.return_value = expected_geographies
         credentials = Credentials('user', '1234')
         catalog = Catalog()
 
         # When
-        datasets = catalog.purchased_datasets(credentials)
+        subscriptions = catalog.subscriptions(credentials)
 
         # Then
-        mocked_purchased_datasets.assert_called_once_with({}, credentials)
-        assert datasets == expected_datasets
+        mocked_datasets.assert_called_once_with({}, credentials)
+        mocked_geographies.assert_called_once_with({}, credentials)
+        assert isinstance(subscriptions, Subscriptions)
+        assert subscriptions.datasets == expected_datasets
+        assert subscriptions.geographies == expected_geographies
+
+    @patch.object(CatalogDataset, 'get_all')
+    @patch.object(Geography, 'get_all')
+    @patch('cartoframes.auth.defaults.get_default_credentials')
+    def test_subscriptions_default_credentials(self, mocked_credentials, mocked_geographies, mocked_datasets):
+        # Given
+        expected_datasets = [test_dataset1, test_dataset2]
+        expected_geographies = [test_geography1, test_geography2]
+        expected_credentials = Credentials('user', '1234')
+        mocked_datasets.return_value = expected_datasets
+        mocked_geographies.return_value = expected_geographies
+        mocked_credentials.return_value = expected_credentials
+        catalog = Catalog()
+
+        # When
+        subscriptions = catalog.subscriptions()
+
+        # Then
+        mocked_datasets.assert_called_once_with({}, expected_credentials)
+        mocked_geographies.assert_called_once_with({}, expected_credentials)
+        assert isinstance(subscriptions, Subscriptions)
+        assert subscriptions.datasets == expected_datasets
+        assert subscriptions.geographies == expected_geographies
+
+    @patch.object(CatalogDataset, 'get_all')
+    @patch.object(Geography, 'get_all')
+    def test_subscriptions_wrong_credentials(self, mocked_geographies, mocked_datasets):
+        # Given
+        wrong_credentials = 1234
+        catalog = Catalog()
+
+        # When
+        with pytest.raises(ValueError) as e:
+            catalog.subscriptions(wrong_credentials)
+
+        # Then
+        assert str(e.value) == '`credentials` must be a Credentials class instance'
diff --git a/test/data/observatory/test_dataset.py b/test/data/observatory/test_dataset.py
index d7b503e7..2219bc18 100644
--- a/test/data/observatory/test_dataset.py
+++ b/test/data/observatory/test_dataset.py
@@ -1,3 +1,4 @@
+import pytest
 import unittest
 import pandas as pd
 
@@ -11,9 +12,10 @@ from cartoframes.data.observatory.dataset import CatalogDataset
 from cartoframes.data.observatory.repository.variable_repo import VariableRepository
 from cartoframes.data.observatory.repository.variable_group_repo import VariableGroupRepository
 from cartoframes.data.observatory.repository.dataset_repo import DatasetRepository
+from cartoframes.data.observatory.subscription_info import SubscriptionInfo
 from .examples import test_dataset1, test_datasets, test_variables, test_variables_groups, db_dataset1, test_dataset2, \
-    db_dataset2
-from .mocks import BigQueryClientMock, CredentialsMock
+    db_dataset2, test_subscription_info
+from .mocks import BigQueryClientMock
 
 try:
     from unittest.mock import Mock, patch
@@ -182,6 +184,7 @@ class TestDataset(unittest.TestCase):
         # Then
         assert isinstance(datasets, list)
         assert isinstance(datasets, CatalogList)
+        assert datasets == test_datasets
 
     @patch.object(DatasetRepository, 'get_all')
     def test_get_all_datasets_credentials(self, mocked_repo):
@@ -196,6 +199,7 @@ class TestDataset(unittest.TestCase):
         mocked_repo.assert_called_once_with(None, credentials)
         assert isinstance(datasets, list)
         assert isinstance(datasets, CatalogList)
+        assert datasets == test_datasets
 
     def test_dataset_list_is_printed_with_classname_and_slugs(self):
         # Given
@@ -256,7 +260,7 @@ class TestDataset(unittest.TestCase):
 
         # test
         username = 'fake_user'
-        credentials = CredentialsMock(username)
+        credentials = Credentials(username, '1234')
 
         dataset = CatalogDataset.get(test_dataset1.id)
         response = dataset.download(credentials)
@@ -274,8 +278,125 @@ class TestDataset(unittest.TestCase):
 
         # test
         username = 'fake_user'
-        credentials = CredentialsMock(username)
+        credentials = Credentials(username, '1234')
 
         dataset = CatalogDataset.get(test_dataset1.id)
         with self.assertRaises(CartoException):
             dataset.download(credentials)
+
+    @patch('cartoframes.data.observatory.subscriptions.get_subscription_ids')
+    @patch('cartoframes.data.observatory.utils.display_subscription_form')
+    @patch('cartoframes.data.observatory.utils.display_existing_subscription_message')
+    def test_dataset_subscribe(self, mock_display_message, mock_display_form, mock_subscription_ids):
+        # Given
+        expected_id = db_dataset1['id']
+        expected_subscribed_ids = []
+        mock_subscription_ids.return_value = expected_subscribed_ids
+        credentials = Credentials('user', '1234')
+        dataset = CatalogDataset(db_dataset1)
+
+        # When
+        dataset.subscribe(credentials)
+
+        # Then
+        mock_subscription_ids.assert_called_once_with(credentials)
+        mock_display_form.assert_called_once_with(expected_id, 'dataset', credentials)
+        assert not mock_display_message.called
+
+    @patch('cartoframes.data.observatory.subscriptions.get_subscription_ids')
+    @patch('cartoframes.data.observatory.utils.display_subscription_form')
+    @patch('cartoframes.data.observatory.utils.display_existing_subscription_message')
+    def test_dataset_subscribe_existing(self, mock_display_message, mock_display_form, mock_subscription_ids):
+        # Given
+        expected_id = db_dataset1['id']
+        expected_subscribed_ids = [expected_id]
+        mock_subscription_ids.return_value = expected_subscribed_ids
+        credentials = Credentials('user', '1234')
+        dataset = CatalogDataset(db_dataset1)
+
+        # When
+        dataset.subscribe(credentials)
+
+        # Then
+        mock_subscription_ids.assert_called_once_with(credentials)
+        mock_display_message.assert_called_once_with(expected_id, 'dataset')
+        assert not mock_display_form.called
+
+    @patch('cartoframes.data.observatory.subscriptions.get_subscription_ids')
+    @patch('cartoframes.data.observatory.utils.display_subscription_form')
+    @patch('cartoframes.auth.defaults.get_default_credentials')
+    def test_dataset_subscribe_default_credentials(self, mocked_credentials, mock_display_form, mock_subscription_ids):
+        # Given
+        expected_credentials = Credentials('user', '1234')
+        mocked_credentials.return_value = expected_credentials
+        dataset = CatalogDataset(db_dataset1)
+
+        # When
+        dataset.subscribe()
+
+        # Then
+        mock_subscription_ids.assert_called_once_with(expected_credentials)
+        mock_display_form.assert_called_once_with(db_dataset1['id'], 'dataset', expected_credentials)
+
+    def test_dataset_subscribe_wrong_credentials(self):
+        # Given
+        wrong_credentials = 1234
+        dataset = CatalogDataset(db_dataset1)
+
+        # When
+        with pytest.raises(ValueError) as e:
+            dataset.subscribe(wrong_credentials)
+
+        # Then
+        assert str(e.value) == '`credentials` must be a Credentials class instance'
+
+    @patch('cartoframes.data.observatory.subscription_info.fetch_subscription_info')
+    def test_dataset_subscription_info(self, mock_fetch):
+        # Given
+        mock_fetch.return_value = test_subscription_info
+        credentials = Credentials('user', '1234')
+        dataset = CatalogDataset(db_dataset1)
+
+        # When
+        info = dataset.subscription_info(credentials)
+
+        # Then
+        mock_fetch.assert_called_once_with(db_dataset1['id'], 'dataset', credentials)
+        assert isinstance(info, SubscriptionInfo)
+        assert info.id == test_subscription_info['id']
+        assert info.estimated_delivery_days == test_subscription_info['estimated_delivery_days']
+        assert info.subscription_list_price == test_subscription_info['subscription_list_price']
+        assert info.tos == test_subscription_info['tos']
+        assert info.tos_link == test_subscription_info['tos_link']
+        assert info.licenses == test_subscription_info['licenses']
+        assert info.licenses_link == test_subscription_info['licenses_link']
+        assert info.rights == test_subscription_info['rights']
+        assert str(info) == 'Properties: id, estimated_delivery_days, ' + \
+                            'subscription_list_price, tos, tos_link, ' + \
+                            'licenses, licenses_link, rights'
+
+    @patch('cartoframes.data.observatory.subscription_info.fetch_subscription_info')
+    @patch('cartoframes.auth.defaults.get_default_credentials')
+    def test_dataset_subscription_info_default_credentials(self, mocked_credentials, mock_fetch):
+        # Given
+        expected_credentials = Credentials('user', '1234')
+        mocked_credentials.return_value = expected_credentials
+        dataset = CatalogDataset(db_dataset1)
+
+        # When
+        dataset.subscription_info()
+
+        # Then
+        mock_fetch.assert_called_once_with(db_dataset1['id'], 'dataset', expected_credentials)
+
+    def test_dataset_subscription_info_wrong_credentials(self):
+        # Given
+        wrong_credentials = 1234
+        dataset = CatalogDataset(db_dataset1)
+
+        # When
+        with pytest.raises(ValueError) as e:
+            dataset.subscription_info(wrong_credentials)
+
+        # Then
+        assert str(e.value) == '`credentials` must be a Credentials class instance'
diff --git a/test/data/observatory/test_geography.py b/test/data/observatory/test_geography.py
index d9965f87..93f50c00 100644
--- a/test/data/observatory/test_geography.py
+++ b/test/data/observatory/test_geography.py
@@ -1,3 +1,4 @@
+import pytest
 import unittest
 import pandas as pd
 
@@ -5,12 +6,15 @@ from google.api_core.exceptions import NotFound
 
 from carto.exceptions import CartoException
 
+from cartoframes.auth import Credentials
 from cartoframes.data.observatory.entity import CatalogList
 from cartoframes.data.observatory.geography import Geography
 from cartoframes.data.observatory.repository.geography_repo import GeographyRepository
 from cartoframes.data.observatory.repository.dataset_repo import DatasetRepository
-from .examples import test_geography1, test_geographies, test_datasets, db_geography1, test_geography2, db_geography2
-from .mocks import BigQueryClientMock, CredentialsMock
+from cartoframes.data.observatory.subscription_info import SubscriptionInfo
+from .examples import test_geography1, test_geographies, test_datasets, db_geography1, \
+    test_geography2, db_geography2, test_subscription_info
+from .mocks import BigQueryClientMock
 
 try:
     from unittest.mock import Mock, patch
@@ -159,6 +163,21 @@ class TestGeography(unittest.TestCase):
         assert isinstance(geographies, CatalogList)
         assert geographies == test_geographies
 
+    @patch.object(GeographyRepository, 'get_all')
+    def test_get_all_geographies_credentials(self, mocked_repo):
+        # Given
+        mocked_repo.return_value = test_geographies
+        credentials = Credentials('user', '1234')
+
+        # When
+        geographies = Geography.get_all(credentials=credentials)
+
+        # Then
+        mocked_repo.assert_called_once_with(None, credentials)
+        assert isinstance(geographies, list)
+        assert isinstance(geographies, CatalogList)
+        assert geographies == test_geographies
+
     def test_geography_list_is_printed_with_classname_and_slugs(self):
         # Given
         geographies = CatalogList([test_geography1, test_geography2])
@@ -208,7 +227,7 @@ class TestGeography(unittest.TestCase):
 
     @patch.object(GeographyRepository, 'get_by_id')
     @patch('cartoframes.data.observatory.entity._get_bigquery_client')
-    def test_dataset_download(self, mocked_bq_client, mocked_repo):
+    def test_geography_download(self, mocked_bq_client, mocked_repo):
         # mock geography
         mocked_repo.return_value = test_geography1
 
@@ -218,7 +237,7 @@ class TestGeography(unittest.TestCase):
 
         # test
         username = 'fake_user'
-        credentials = CredentialsMock(username)
+        credentials = Credentials(username, '1234')
 
         dataset = Geography.get(test_geography1.id)
         response = dataset.download(credentials)
@@ -227,7 +246,7 @@ class TestGeography(unittest.TestCase):
 
     @patch.object(GeographyRepository, 'get_by_id')
     @patch('cartoframes.data.observatory.entity._get_bigquery_client')
-    def test_dataset_download_raises_with_nonpurchased(self, mocked_bq_client, mocked_repo):
+    def test_geography_download_raises_with_nonpurchased(self, mocked_bq_client, mocked_repo):
         # mock geography
         mocked_repo.return_value = test_geography1
 
@@ -236,8 +255,126 @@ class TestGeography(unittest.TestCase):
 
         # test
         username = 'fake_user'
-        credentials = CredentialsMock(username)
+        credentials = Credentials(username, '1234')
 
-        dataset = Geography.get(test_geography1.id)
+        geography = Geography.get(test_geography1.id)
         with self.assertRaises(CartoException):
-            dataset.download(credentials)
+            geography.download(credentials)
+
+    @patch('cartoframes.data.observatory.subscriptions.get_subscription_ids')
+    @patch('cartoframes.data.observatory.utils.display_subscription_form')
+    @patch('cartoframes.data.observatory.utils.display_existing_subscription_message')
+    def test_geography_subscribe(self, mock_display_message, mock_display_form, mock_subscription_ids):
+        # Given
+        expected_id = db_geography1['id']
+        expected_subscribed_ids = []
+        mock_subscription_ids.return_value = expected_subscribed_ids
+        credentials = Credentials('user', '1234')
+        geography = Geography(db_geography1)
+
+        # When
+        geography.subscribe(credentials)
+
+        # Then
+        mock_subscription_ids.assert_called_once_with(credentials)
+        mock_display_form.assert_called_once_with(expected_id, 'geography', credentials)
+        assert not mock_display_message.called
+
+    @patch('cartoframes.data.observatory.subscriptions.get_subscription_ids')
+    @patch('cartoframes.data.observatory.utils.display_subscription_form')
+    @patch('cartoframes.data.observatory.utils.display_existing_subscription_message')
+    def test_geography_subscribe_existing(self, mock_display_message, mock_display_form, mock_subscription_ids):
+        # Given
+        expected_id = db_geography1['id']
+        expected_subscribed_ids = [expected_id]
+        mock_subscription_ids.return_value = expected_subscribed_ids
+        credentials = Credentials('user', '1234')
+        geography = Geography(db_geography1)
+
+        # When
+        geography.subscribe(credentials)
+
+        # Then
+        mock_subscription_ids.assert_called_once_with(credentials)
+        mock_display_message.assert_called_once_with(expected_id, 'geography')
+        assert not mock_display_form.called
+
+    @patch('cartoframes.data.observatory.subscriptions.get_subscription_ids')
+    @patch('cartoframes.data.observatory.utils.display_subscription_form')
+    @patch('cartoframes.auth.defaults.get_default_credentials')
+    def test_geography_subscribe_default_credentials(self, mocked_credentials, mock_display_form,
+        mock_subscription_ids):
+        # Given
+        expected_credentials = Credentials('user', '1234')
+        mocked_credentials.return_value = expected_credentials
+        geography = Geography(db_geography1)
+
+        # When
+        geography.subscribe()
+
+        # Then
+        mock_subscription_ids.assert_called_once_with(expected_credentials)
+        mock_display_form.assert_called_once_with(db_geography1['id'], 'geography', expected_credentials)
+
+    def test_geography_subscribe_wrong_credentials(self):
+        # Given
+        wrong_credentials = 1234
+        geography = Geography(db_geography1)
+
+        # When
+        with pytest.raises(ValueError) as e:
+            geography.subscribe(wrong_credentials)
+
+        # Then
+        assert str(e.value) == '`credentials` must be a Credentials class instance'
+
+    @patch('cartoframes.data.observatory.subscription_info.fetch_subscription_info')
+    def test_geography_subscription_info(self, mock_fetch):
+        # Given
+        mock_fetch.return_value = test_subscription_info
+        credentials = Credentials('user', '1234')
+        geography = Geography(db_geography1)
+
+        # When
+        info = geography.subscription_info(credentials)
+
+        # Then
+        mock_fetch.assert_called_once_with(db_geography1['id'], 'geography', credentials)
+        assert isinstance(info, SubscriptionInfo)
+        assert info.id == test_subscription_info['id']
+        assert info.estimated_delivery_days == test_subscription_info['estimated_delivery_days']
+        assert info.subscription_list_price == test_subscription_info['subscription_list_price']
+        assert info.tos == test_subscription_info['tos']
+        assert info.tos_link == test_subscription_info['tos_link']
+        assert info.licenses == test_subscription_info['licenses']
+        assert info.licenses_link == test_subscription_info['licenses_link']
+        assert info.rights == test_subscription_info['rights']
+        assert str(info) == 'Properties: id, estimated_delivery_days, ' + \
+                            'subscription_list_price, tos, tos_link, ' + \
+                            'licenses, licenses_link, rights'
+
+    @patch('cartoframes.data.observatory.subscription_info.fetch_subscription_info')
+    @patch('cartoframes.auth.defaults.get_default_credentials')
+    def test_geography_subscription_info_default_credentials(self, mocked_credentials, mock_fetch):
+        # Given
+        expected_credentials = Credentials('user', '1234')
+        mocked_credentials.return_value = expected_credentials
+        geography = Geography(db_geography1)
+
+        # When
+        geography.subscription_info()
+
+        # Then
+        mock_fetch.assert_called_once_with(db_geography1['id'], 'geography', expected_credentials)
+
+    def test_geography_subscription_info_wrong_credentials(self):
+        # Given
+        wrong_credentials = 1234
+        geography = Geography(db_geography1)
+
+        # When
+        with pytest.raises(ValueError) as e:
+            geography.subscription_info(wrong_credentials)
+
+        # Then
+        assert str(e.value) == '`credentials` must be a Credentials class instance'