├── .github ├── FUNDING.yml └── workflows │ ├── stale.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── codecov.yml ├── metabase_api ├── __init__.py ├── _helper_methods.py ├── _helper_methods_async.py ├── _rest_methods.py ├── _rest_methods_async.py ├── copy_methods.py ├── copy_methods_async.py ├── create_methods.py ├── create_methods_async.py ├── metabase_api.py └── metabase_api_async.py ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── data ├── metabase.png └── test_db.sqlite ├── initial_setup.sh ├── readme.md └── test_metabase_api.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [vvaezian] 2 | 3 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close inactive Issues and PRs 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | steps: 13 | - uses: actions/stale@v5 14 | with: 15 | days-before-issue-stale: 15 16 | days-before-issue-close: 15 17 | stale-issue-label: "stale" 18 | stale-issue-message: "This issue is stale because it has been open for 15 days with no activity." 19 | close-issue-message: "This issue was closed because it has been inactive for 15 days since being marked as stale." 20 | stale-pr-label: "stale" 21 | days-before-pr-stale: 15 22 | days-before-pr-close: 15 23 | stale-pr-message: "This PR is stale because it has been open for 15 days with no activity." 24 | close-pr-message: "This PR was closed because it has been inactive for 15 days since being marked as stale." 25 | exempt-all-assignees: true 26 | exempt-draft-pr: true 27 | repo-token: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: [ master, dev ] 6 | paths: 7 | - 'metabase_api/**' 8 | - 'tests/**' 9 | - '.github/workflows/test*' 10 | pull_request: 11 | branches: [ master ] 12 | paths: 13 | - 'metabase_api/**' 14 | - 'tests/**' 15 | 16 | jobs: 17 | build: 18 | if: github.event.pull_request.draft == false 19 | runs-on: ubuntu-latest 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | python-version: ["3.x"] 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v2 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | python -m pip install flake8 pytest 35 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 36 | python -m pip install requests 37 | - name: Lint with flake8 38 | run: | 39 | # stop the build if there are Python syntax errors or undefined names 40 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 41 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 42 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 43 | - name: Initial Setup before running tests 44 | run: | 45 | chmod +x ./tests/initial_setup.sh 46 | ./tests/initial_setup.sh -v 0.49.9 47 | - name: Test with pytest 48 | run: | 49 | pytest 50 | - name: Generate Report 51 | run: | 52 | pip install coverage 53 | coverage run -m unittest 54 | - name: Upload Coverage to Codecov 55 | uses: codecov/codecov-action@v1 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/*.ipynb 2 | **/__pycache__/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 3.5 2 | ### Added 3 | - Asynchronous API support using `httpx` 4 | - New class `Metabase_API_Async` for async operations 5 | - Async versions of core API methods 6 | 7 | ## 3.4.5.1 8 | ### Changed 9 | - Fix [#59](https://github.com/vvaezian/metabase_api_python/issues/59) 10 | - Improve docs styling 11 | 12 | ## 3.4.4 13 | ### Changed 14 | - Make sure the provided API key is correct. 15 | 16 | ## 3.4.3 17 | ### Changed 18 | - PR [#56](https://github.com/vvaezian/metabase_api_python/pull/56) 19 | 20 | ## 3.4.2 21 | ### Changed 22 | - Added 'return_card' argument to the `copy_card` function. 23 | 24 | ## 3.4.1 25 | ### Changed 26 | - PR #54 (fixes a typo in the `clone_card` function) 27 | 28 | ## 3.4 29 | ### Changed 30 | - New versioning format (3.4 instead of 0.3.4) 31 | - Added `format_rows` option to `get_card_data` function 32 | 33 | ## 0.3.3 34 | ### Changed 35 | - Authentication using API key 36 | 37 | ## 0.3.2 38 | ### Changed 39 | - Refactored for API changes introduced in Metabase v48 (`ordered_cards` -> `dashcards`) 40 | 41 | ## 0.3.1 42 | ### Changed 43 | - Fixed a typo in create_card function (PR [#47](https://github.com/vvaezian/metabase_api_python/pull/47)) 44 | 45 | ## 0.3.0 46 | ### Changed 47 | - Option for local unittest is added. Also GitHub Actions Workflow is modified to use local testing. 48 | - Fixed the issue #37 ([Make wrapper more maintenance-proof with non-breaking refactor](https://github.com/vvaezian/metabase_api_python/issues/37)) 49 | 50 | ## 0.2.16 51 | ### Changed 52 | - Fixed the issue #41 ([KeyError: 'sizeX'](https://github.com/vvaezian/metabase_api_python/issues/41)) 53 | 54 | ## 0.2.15 55 | ### Changed 56 | - Fixed the issue #33 ([Missing step in the clone_card function](https://github.com/vvaezian/metabase_api_python/issues/33)) 57 | 58 | ## 0.2.14.2 59 | ### Changed 60 | - Fixed the issue #31 ([Unable to use get_columns_name_id as a non-superuser](https://github.com/vvaezian/metabase_api_python/issues/31)) 61 | 62 | ## 0.2.14 63 | ### Added 64 | - "Allow passing filter values to `get_card_data` function" ([#25](https://github.com/vvaezian/metabase_api_python/issues/25)). 65 | - "Add `add_card_to_dashboard` custom function" (PR [#26](https://github.com/vvaezian/metabase_api_python/pull/26)). 66 | - `get_item_info` function 67 | ### Changed 68 | - "Copy collection to root collection does not work" ([#23](https://github.com/vvaezian/metabase_api_python/issues/23)). 69 | - Expanded the `get_item_id` and `get_item_name` functions to cover all item types ([#28](https://github.com/vvaezian/metabase_api_python/issues/28)). 70 | - `clone_card` function now also works for simple/custom questions ([#27](https://github.com/vvaezian/metabase_api_python/issues/27)). 71 | - `clone_card` function now replaces table name in the query text for native questions. 72 | 73 | ## 0.2.13 74 | ### Added 75 | - `create_collection` function 76 | ### Changed 77 | - Fixed the issues #20 and #22. 78 | - Changed the behavior of the `copy_collection` function. Previously it would copy only the content of the source collection, but now copies the contents together with source collection itself. 79 | In other words, now a new collection with the same name as the source collection is created in the destination and the content of the source collection is copied into it. 80 | - Improved the function `make_json`. 81 | 82 | ## 0.2.12 83 | ### Added 84 | - `clone_card` function 85 | ### Changed 86 | - Fixed the issues #12. 87 | - Updated the `search` and `get_db_id` functions to reflect the changes in v.40 of Metabase. 88 | - Updated the docstring of the `update_column` function to reflect the changes in v.39 of Metabase. 89 | 90 | ## 0.2.11 (2021-05-03) 91 | ### Added 92 | - `search` function (Endpoint: [`GET /api/search/`](https://www.metabase.com/docs/latest/api-documentation.html#get-apisearch)) 93 | - `get_card_data` function for getting data of the questions (Endpoint: [`POST /api/card/:card-id/query/:export-format`](https://www.metabase.com/docs/latest/api-documentation.html#post-apicardcard-idqueryexport-format)) 94 | 95 | ## 0.2.10 (2021-04-19) 96 | ### Added 97 | - Basic Auth ([PR](https://github.com/vvaezian/metabase_api_python/pull/16)) 98 | 99 | ## 0.2.9 (2021-04-05) 100 | ## 0.2.8 (2021-02-01) 101 | ## 0.2.7 (2020-11-22) 102 | ## 0.2.6 (2020-11-01) 103 | ## 0.2.5 (2020-10-12) 104 | ## 0.2.4 (2020-09-19) 105 | ## 0.2.3 (2020-09-05) 106 | ## 0.2.2 (2020-05-28) 107 | ## 0.2.1 (2020-04-30) 108 | ## 0.2.0 (2020-04-30) 109 | ## 0.1.4 (2020-02-21) 110 | ## 0.1.3 (2020-02-08) 111 | ## 0.1.2 (2020-02-07) 112 | ## 0.1.1 (2020-01-22) 113 | ## 0.1 (2020-01-21) 114 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT Licence (MIT) 2 | 3 | Copyright (c) 2025 Vahid Vaezian 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![PyPI version](https://badge.fury.io/py/metabase-api.svg?)](https://badge.fury.io/py/metabase-api) 3 | [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](https://github.com/vvaezian/metabase_api_python/issues) 4 | [![codecov](https://codecov.io/gh/vvaezian/metabase_api_python/branch/master/graph/badge.svg?token=FNH20CUC4F)](https://codecov.io/gh/vvaezian/metabase_api_python) 5 | [![GitHub license](https://img.shields.io/github/license/vvaezian/metabase_api_python.svg)](https://github.com/vvaezian/metabase_api_python/blob/master/LICENSE) 6 | 7 | ## Installation 8 | ```python 9 | pip install metabase-api 10 | ``` 11 | 12 | ## Initializing 13 | 14 | #### Synchronous 15 | ```python 16 | from metabase_api import Metabase_API 17 | 18 | # authentication using username/password 19 | mb = Metabase_API('https://...', 'username', 'password') # if password is not given, it will prompt for password 20 | 21 | # authentication using API key 22 | mb = Metabase_API('https://...', api_key='YOUR_API_KEY') 23 | ``` 24 | 25 | #### Async 26 | 27 | ```python 28 | from metabase_api import Metabase_API_Async 29 | 30 | # authentication using username/password 31 | mb = Metabase_API_Async('https://...', 'username', 'password') # if password is not given, it will prompt for password 32 | 33 | # authentication using API key 34 | mb = Metabase_API_Async('https://...', api_key='YOUR_API_KEY') 35 | ``` 36 | 37 | ## Functions 38 | ### REST functions (get, post, put, delete) 39 | Calling Metabase API endpoints (documented [here](https://github.com/metabase/metabase/blob/master/docs/api-documentation.md)) can be done using the corresponding REST function in the wrapper. 40 | E.g. to call the [endpoint](https://github.com/metabase/metabase/blob/master/docs/api-documentation.md#get-apidatabase) `GET /api/database/`, use `mb.get('/api/database/')`. 41 | 42 | For async operations, use `await mb_async.get('/api/database/')`. 43 | 44 | ### Helper Functions 45 | You usually don't need to deal with these functions directly (e.g. [get_item_info](https://github.com/vvaezian/metabase_api_python/blob/77ef837972bc169f96a3ca520da769e0b933e8a8/metabase_api/metabase_api.py#L89), [get_item_id](https://github.com/vvaezian/metabase_api_python/blob/77ef837972bc169f96a3ca520da769e0b933e8a8/metabase_api/metabase_api.py#L128), [get_item_name](https://github.com/vvaezian/metabase_api_python/blob/77ef837972bc169f96a3ca520da769e0b933e8a8/metabase_api/metabase_api.py#L116)) 46 | 47 | ### Custom Functions 48 | 49 | - [create_card](https://github.com/vvaezian/metabase_api_python/blob/150c8143bf3ec964568d54bddd80bf9c1b2ca214/metabase_api/metabase_api.py#L289) 50 | - [create_collection](https://github.com/vvaezian/metabase_api_python/blob/150c8143bf3ec964568d54bddd80bf9c1b2ca214/metabase_api/metabase_api.py#L461) 51 | - [create_segment](https://github.com/vvaezian/metabase_api_python/blob/150c8143bf3ec964568d54bddd80bf9c1b2ca214/metabase_api/metabase_api.py#L486) 52 | - [copy_card](https://github.com/vvaezian/metabase_api_python/blob/150c8143bf3ec964568d54bddd80bf9c1b2ca214/metabase_api/metabase_api.py#L530) 53 | - [copy_pulse](https://github.com/vvaezian/metabase_api_python/blob/150c8143bf3ec964568d54bddd80bf9c1b2ca214/metabase_api/metabase_api.py#L591) 54 | - [copy_dashboard](https://github.com/vvaezian/metabase_api_python/blob/150c8143bf3ec964568d54bddd80bf9c1b2ca214/metabase_api/metabase_api.py#L643) 55 | - [copy_collection](https://github.com/vvaezian/metabase_api_python/blob/150c8143bf3ec964568d54bddd80bf9c1b2ca214/metabase_api/metabase_api.py#L736) 56 | - [clone_card](https://github.com/vvaezian/metabase_api_python/blob/77ef837972bc169f96a3ca520da769e0b933e8a8/metabase_api/metabase_api.py#L1003) 57 | - [update_column](https://github.com/vvaezian/metabase_api_python/blob/77ef837972bc169f96a3ca520da769e0b933e8a8/metabase_api/metabase_api.py#L1146) 58 | - [search](https://github.com/vvaezian/metabase_api_python/blob/150c8143bf3ec964568d54bddd80bf9c1b2ca214/metabase_api/metabase_api.py#L835) 59 | - [get_card_data](https://github.com/vvaezian/metabase_api_python/blob/77ef837972bc169f96a3ca520da769e0b933e8a8/metabase_api/metabase_api.py#L966) 60 | - [move_to_archive](https://github.com/vvaezian/metabase_api_python/blob/150c8143bf3ec964568d54bddd80bf9c1b2ca214/metabase_api/metabase_api.py#L933) 61 | - [delete_item](https://github.com/vvaezian/metabase_api_python/blob/150c8143bf3ec964568d54bddd80bf9c1b2ca214/metabase_api/metabase_api.py#L963) 62 | - [make_json](https://github.com/vvaezian/metabase_api_python/blob/150c8143bf3ec964568d54bddd80bf9c1b2ca214/metabase_api/metabase_api.py#L1015) 63 | 64 | *For a complete list of functions parameters see the functions definitions using the above links. Here we provide a short description:* 65 | 66 | - #### `create_card` 67 | Specify the name to be used for the card, which table (name/id) to use as the source of data and where (i.e. which collection (name/id)) to save the card (default is the root collection). 68 | ```python 69 | mb.create_card(card_name='test_card', table_name='mySourceTable') # Setting `verbose=True` will print extra information while creating the card. 70 | ``` 71 | Using the `column_order` parameter we can specify how the order of columns should be in the created card. Accepted values are *'alphabetical', 'db_table_order'* (default), or a list of column names. 72 | ```python 73 | mb.create_card(card_name='test_card', table_name='mySourceTable', column_order=['myCol5', 'myCol3', 'myCol8']) 74 | ``` 75 | All or part of the function parameters and many more information (e.g. visualisation settings) can be provided to the function in a dictionary, using the *custom_json* parameter. (also see the `make_json` function below) 76 | ```python 77 | q = ''' 78 | select * 79 | from my_table 80 | where city = '{}' 81 | ''' 82 | 83 | for city in city_list: 84 | 85 | query = q.format(city) 86 | 87 | # here I included the minimum keys required. You can add more. 88 | my_custom_json = { 89 | 'name': 'test_card', 90 | 'display': 'table', 91 | 'dataset_query': { 92 | 'database': db_id, 93 | 'native': { 'query': query }, 94 | 'type': 'native' 95 | } 96 | } 97 | 98 | # See the function definition for other parameters of the function (e.g. in which collection to save the card) 99 | mb.create_card(custom_json=my_custom_json) 100 | ``` 101 | 102 | - #### `create_collection` 103 | Create an empty collection. Provide the name of the collection, and the name or id of the parent collection (i.e. where you want the created collection to reside). If you want to create the collection in the root, you need to provide `parent_collection_name='Root'`. 104 | ```python 105 | mb.create_collection(collection_name='test_collection', parent_collection_id=123) 106 | ``` 107 | 108 | - #### `create_segment` 109 | Provide the name to be used for creating the segment, the name or id of the table you want to create the segment on, the column of that table to filter on and the filter values. 110 | ```python 111 | mb.create_segment(segment_name='test_segment', table_name='user_table', column_name='user_id', column_values=[123, 456, 789]) 112 | ``` 113 | 114 | - #### `copy_card` 115 | At the minimum you need to provide the name/id of the card to copy and the name/id of the collection to copy the card to. 116 | ```python 117 | mb.copy_card(source_card_name='test_card', destination_collection_id=123) 118 | ``` 119 | 120 | - #### `copy_pulse` 121 | Similar to `copy_card` but for pulses. 122 | ```python 123 | mb.copy_pulse(source_pulse_name='test_pulse', destination_collection_id=123) 124 | ``` 125 | 126 | - #### `copy_dashboard` 127 | You can determine whether you want to *deepcopy* the dashboard or not (default False). 128 | If you don't deepcopy, the duplicated dashboard will use the same cards as the original dashboard. 129 | When you deepcopy a dashboard, the cards of the original dashboard are duplicated and these cards are used in the duplicate dashboard. 130 | If the `destination_dashboard_name` parameter is not provided, the destination dashboard name will be the same as the source dashboard name (plus any `postfix` if provided). 131 | The duplicated cards (in case of deepcopying) are saved in a collection called `[destination_dashboard_name]'s cards` and placed in the same collection as the duplicated dashboard. 132 | ```python 133 | mb.copy_dashboard(source_dashboard_id=123, destination_collection_id=456, deepcopy=True) 134 | ``` 135 | 136 | - #### `copy_collection` 137 | Copies the given collection and its contents to the given `destination_parent_collection` (name/id). You can determine whether to deepcopy the dashboards. 138 | ```python 139 | mb.copy_collection(source_collection_id=123, destination_parent_collection_id=456, deepcopy_dashboards=True, verbose=True) 140 | ``` 141 | You can also specify a postfix to be added to the names of the child items that get copied. 142 | 143 | - #### `clone_card` 144 | Similar to `copy_card` but a different table is used as the source for filters of the card. 145 | This comes in handy when you want to create similar cards with the same filters that differ only on the source of the filters (e.g. cards for 50 US states). 146 | ```python 147 | mb.clone_card(card_id=123, source_table_id=456, target_table_id=789, new_card_name='test clone', new_card_collection_id=1) 148 | ``` 149 | 150 | - #### `update_column` 151 | Update the column in Data Model by providing the relevant parameter (list of all parameters can be found [here](https://www.metabase.com/docs/latest/api-documentation.html#put-apifieldid)). 152 | For example to change the column type to 'Category', we can use: 153 | ```python 154 | mb.update_column(column_name='myCol', table_name='myTable', params={'semantic_type':'type/Category'} # (For Metabase versions before v.39, use: params={'special_type':'type/Category'})) 155 | ``` 156 | 157 | - #### `search` 158 | Searches for Metabase objects and returns basic info. 159 | Provide the search term and optionally `item_type` to limit the results. 160 | ```Python 161 | mb.search(q='test', item_type='card') 162 | ``` 163 | 164 | - #### `get_card_data` 165 | Returns the rows. 166 | Provide the card name/id and the data format of the output (csv or json). You can also provide filter values. 167 | ```python 168 | results = mb.get_card_data(card_id=123, data_format='csv') 169 | ``` 170 | 171 | - #### `make_json` 172 | It's very helpful to use the Inspect tool of the browser (network tab) to see what Metabase is doing. You can then use the generated json code to build your automation. To turn the generated json in the browser into a Python dictionary, you can copy the code, paste it into triple quotes (`''' '''`) and apply the function `make_json`: 173 | ```python 174 | raw_json = ''' {"name":"test","dataset_query":{"database":165,"query":{"fields":[["field-id",35839],["field-id",35813],["field-id",35829],["field-id",35858],["field-id",35835],["field-id",35803],["field-id",35843],["field-id",35810],["field-id",35826],["field-id",35815],["field-id",35831],["field-id",35827],["field-id",35852],["field-id",35832],["field-id",35863],["field-id",35851],["field-id",35850],["field-id",35864],["field-id",35854],["field-id",35846],["field-id",35811],["field-id",35933],["field-id",35862],["field-id",35833],["field-id",35816]],"source-table":2154},"type":"query"},"display":"table","description":null,"visualization_settings":{"table.column_formatting":[{"columns":["Diff"],"type":"range","colors":["#ED6E6E","white","#84BB4C"],"min_type":"custom","max_type":"custom","min_value":-30,"max_value":30,"operator":"=","value":"","color":"#509EE3","highlight_row":false}],"table.pivot_column":"Sale_Date","table.cell_column":"SKUID"},"archived":false,"enable_embedding":false,"embedding_params":null,"collection_id":183,"collection_position":null,"result_metadata":[{"name":"Sale_Date","display_name":"Sale_Date","base_type":"type/DateTime","fingerprint":{"global":{"distinct-count":1,"nil%":0},"type":{"type/DateTime":{"earliest":"2019-12-28T00:00:00","latest":"2019-12-28T00:00:00"}}},"special_type":null},{"name":"Account_ID","display_name":"Account_ID","base_type":"type/Text","fingerprint":{"global":{"distinct-count":411,"nil%":0},"type":{"type/Text":{"percent-json":0,"percent-url":0,"percent-email":0,"average-length":9}}},"special_type":null},{"name":"Account_Name","display_name":"Account_Name","base_type":"type/Text","fingerprint":{"global":{"distinct-count":410,"nil%":0.0015},"type":{"type/Text":{"percent-json":0,"percent-url":0,"percent-email":0,"average-length":21.2916}}},"special_type":null},{"name":"Account_Type","display_name":"Account_Type","base_type":"type/Text","special_type":"type/Category","fingerprint":{"global":{"distinct-count":5,"nil%":0.0015},"type":{"type/Text":{"percent-json":0,"percent-url":0,"percent-email":0,"average-length":3.7594}}}}],"metadata_checksum":"7XP8bmR1h5f662CFE87tjQ=="} ''' 175 | myJson = mb.make_json(raw_json) # setting 'prettyprint=True' will print the output in a structured format. 176 | mb.create_card('test_card2', table_name='mySourceTable', custom_json={'visualization_settings':myJson['visualization_settings']}) 177 | ``` 178 | 179 | - #### `move_to_archive` 180 | Moves the item (Card, Dashboard, Collection, Pulse, Segment) to the Archive section. 181 | ```python 182 | mb.move_to_archive('card', item_id=123) 183 | ``` 184 | - #### `delete_item` 185 | Deletes the item (Card, Dashboard, Pulse). Currently Collections and Segments cannot be deleted using the Metabase API. 186 | ```python 187 | mb.delete_item('card', item_id=123) 188 | ``` 189 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "*/tests/*” 3 | -------------------------------------------------------------------------------- /metabase_api/__init__.py: -------------------------------------------------------------------------------- 1 | from .metabase_api import Metabase_API 2 | from .metabase_api_async import Metabase_API_Async 3 | -------------------------------------------------------------------------------- /metabase_api/_helper_methods.py: -------------------------------------------------------------------------------- 1 | 2 | def get_item_info(self, item_type 3 | , item_id=None, item_name=None 4 | , collection_id=None, collection_name=None 5 | , params=None): 6 | ''' 7 | Return the info for the given item. 8 | Use 'params' for providing arguments. E.g. to include tables in the result for databases, use: params={'include':'tables'} 9 | ''' 10 | 11 | assert item_type in ['database', 'table', 'card', 'collection', 'dashboard', 'pulse', 'segment'] 12 | 13 | if params: 14 | assert type(params) == dict 15 | 16 | if not item_id: 17 | if not item_name: 18 | raise ValueError('Either the name or id of the {} must be provided.'.format(item_type)) 19 | item_id = self.get_item_id(item_type, item_name, collection_id=collection_id, collection_name=collection_name) 20 | 21 | res = self.get("/api/{}/{}".format(item_type, item_id), params=params) 22 | if res: 23 | return res 24 | else: 25 | raise ValueError('There is no {} with the id "{}"'.format(item_type, item_id)) 26 | 27 | 28 | 29 | def get_item_name(self, item_type, item_id): 30 | 31 | assert item_type in ['database', 'table', 'card', 'collection', 'dashboard', 'pulse', 'segment'] 32 | 33 | res = self.get("/api/{}/{}".format(item_type, item_id)) 34 | if res: 35 | return res['name'] 36 | else: 37 | raise ValueError('There is no {} with the id "{}"'.format(item_type, item_id)) 38 | 39 | 40 | 41 | def get_item_id(self, item_type, item_name, collection_id=None, collection_name=None, db_id=None, db_name=None, table_id=None): 42 | 43 | assert item_type in ['database', 'table', 'card', 'collection', 'dashboard', 'pulse', 'segment'] 44 | 45 | if item_type in ['card', 'dashboard', 'pulse']: 46 | if not collection_id: 47 | if not collection_name: 48 | # Collection name/id is not provided. Searching in all collections 49 | item_IDs = [ i['id'] for i in self.get("/api/{}/".format(item_type)) if i['name'] == item_name 50 | and i['archived'] == False ] 51 | else: 52 | collection_id = self.get_item_id('collection', collection_name) if collection_name != 'root' else None 53 | item_IDs = [ i['id'] for i in self.get("/api/{}/".format(item_type)) if i['name'] == item_name 54 | and i['collection_id'] == collection_id 55 | and i['archived'] == False ] 56 | else: 57 | collection_name = self.get_item_name('collection', collection_id) 58 | item_IDs = [ i['id'] for i in self.get("/api/{}/".format(item_type)) if i['name'] == item_name 59 | and i['collection_id'] == collection_id 60 | and i['archived'] == False ] 61 | 62 | if len(item_IDs) > 1: 63 | if not collection_name: 64 | raise ValueError('There is more than one {} with the name "{}".\n\ 65 | Provide collection id/name to limit the search space'.format(item_type, item_name)) 66 | raise ValueError('There is more than one {} with the name "{}" in the collection "{}"' 67 | .format(item_type, item_name, collection_name)) 68 | if len(item_IDs) == 0: 69 | if not collection_name: 70 | raise ValueError('There is no {} with the name "{}"'.format(item_type, item_name)) 71 | raise ValueError('There is no item with the name "{}" in the collection "{}"' 72 | .format(item_name, collection_name)) 73 | 74 | return item_IDs[0] 75 | 76 | 77 | if item_type == 'collection': 78 | collection_IDs = [ i['id'] for i in self.get("/api/collection/") if i['name'] == item_name ] 79 | 80 | if len(collection_IDs) > 1: 81 | raise ValueError('There is more than one collection with the name "{}"'.format(item_name)) 82 | if len(collection_IDs) == 0: 83 | raise ValueError('There is no collection with the name "{}"'.format(item_name)) 84 | 85 | return collection_IDs[0] 86 | 87 | 88 | if item_type == 'database': 89 | res = self.get("/api/database/") 90 | if type(res) == dict: # in Metabase version *.40.0 the format of the returned result for this endpoint changed 91 | res = res['data'] 92 | db_IDs = [ i['id'] for i in res if i['name'] == item_name ] 93 | 94 | if len(db_IDs) > 1: 95 | raise ValueError('There is more than one DB with the name "{}"'.format(item_name)) 96 | if len(db_IDs) == 0: 97 | raise ValueError('There is no DB with the name "{}"'.format(item_name)) 98 | 99 | return db_IDs[0] 100 | 101 | 102 | if item_type == 'table': 103 | tables = self.get("/api/table/") 104 | 105 | if db_id: 106 | table_IDs = [ i['id'] for i in tables if i['name'] == item_name and i['db']['id'] == db_id ] 107 | elif db_name: 108 | table_IDs = [ i['id'] for i in tables if i['name'] == item_name and i['db']['name'] == db_name ] 109 | else: 110 | table_IDs = [ i['id'] for i in tables if i['name'] == item_name ] 111 | 112 | if len(table_IDs) > 1: 113 | raise ValueError('There is more than one table with the name {}. Provide db id/name.'.format(item_name)) 114 | if len(table_IDs) == 0: 115 | raise ValueError('There is no table with the name "{}" (in the provided db, if any)'.format(item_name)) 116 | 117 | return table_IDs[0] 118 | 119 | 120 | if item_type == 'segment': 121 | segment_IDs = [ i['id'] for i in self.get("/api/segment/") if i['name'] == item_name 122 | and (not table_id or i['table_id'] == table_id) ] 123 | if len(segment_IDs) > 1: 124 | raise ValueError('There is more than one segment with the name "{}"'.format(item_name)) 125 | if len(segment_IDs) == 0: 126 | raise ValueError('There is no segment with the name "{}"'.format(item_name)) 127 | 128 | return segment_IDs[0] 129 | 130 | 131 | 132 | def get_collection_id(self, collection_name): 133 | import warnings 134 | warnings.warn("The function get_collection_id will be removed in the next version. Use get_item_id function instead.", DeprecationWarning) 135 | 136 | collection_IDs = [ i['id'] for i in self.get("/api/collection/") if i['name'] == collection_name ] 137 | 138 | if len(collection_IDs) > 1: 139 | raise ValueError('There is more than one collection with the name "{}"'.format(collection_name)) 140 | if len(collection_IDs) == 0: 141 | raise ValueError('There is no collection with the name "{}"'.format(collection_name)) 142 | 143 | return collection_IDs[0] 144 | 145 | 146 | 147 | def get_db_id(self, db_name): 148 | import warnings 149 | warnings.warn("The function get_db_id will be removed in the next version. Use get_item_id function instead.", DeprecationWarning) 150 | 151 | res = self.get("/api/database/") 152 | if type(res) == dict: # in Metabase version *.40.0 the format of the returned result for this endpoint changed 153 | res = res['data'] 154 | db_IDs = [ i['id'] for i in res if i['name'] == db_name ] 155 | 156 | if len(db_IDs) > 1: 157 | raise ValueError('There is more than one DB with the name "{}"'.format(db_name)) 158 | if len(db_IDs) == 0: 159 | raise ValueError('There is no DB with the name "{}"'.format(db_name)) 160 | 161 | return db_IDs[0] 162 | 163 | 164 | 165 | def get_table_id(self, table_name, db_name=None, db_id=None): 166 | import warnings 167 | warnings.warn("The function get_table_id will be removed in the next version. Use get_item_id function instead.", DeprecationWarning) 168 | 169 | tables = self.get("/api/table/") 170 | 171 | if db_id: 172 | table_IDs = [ i['id'] for i in tables if i['name'] == table_name and i['db']['id'] == db_id ] 173 | elif db_name: 174 | table_IDs = [ i['id'] for i in tables if i['name'] == table_name and i['db']['name'] == db_name ] 175 | else: 176 | table_IDs = [ i['id'] for i in tables if i['name'] == table_name ] 177 | 178 | if len(table_IDs) > 1: 179 | raise ValueError('There is more than one table with the name {}. Provide db id/name.'.format(table_name)) 180 | if len(table_IDs) == 0: 181 | raise ValueError('There is no table with the name "{}" (in the provided db, if any)'.format(table_name)) 182 | 183 | return table_IDs[0] 184 | 185 | 186 | 187 | def get_segment_id(self, segment_name, table_id=None): 188 | import warnings 189 | warnings.warn("The function get_segment_id will be removed in the next version. Use get_item_id function instead.", DeprecationWarning) 190 | 191 | segment_IDs = [ i['id'] for i in self.get("/api/segment/") if i['name'] == segment_name 192 | and (not table_id or i['table_id'] == table_id) ] 193 | if len(segment_IDs) > 1: 194 | raise ValueError('There is more than one segment with the name "{}"'.format(segment_name)) 195 | if len(segment_IDs) == 0: 196 | raise ValueError('There is no segment with the name "{}"'.format(segment_name)) 197 | 198 | return segment_IDs[0] 199 | 200 | 201 | 202 | def get_db_id_from_table_id(self, table_id): 203 | tables = [ i['db_id'] for i in self.get("/api/table/") if i['id'] == table_id ] 204 | 205 | if len(tables) == 0: 206 | raise ValueError('There is no DB containing the table with the ID "{}"'.format(table_id)) 207 | 208 | return tables[0] 209 | 210 | 211 | 212 | def get_db_info(self, db_name=None, db_id=None, params=None): 213 | ''' 214 | Return Database info. Use 'params' for providing arguments. 215 | For example to include tables in the result, use: params={'include':'tables'} 216 | ''' 217 | import warnings 218 | warnings.warn("The function get_db_info will be removed in the next version. Use get_item_info function instead.", DeprecationWarning) 219 | 220 | if params: 221 | assert type(params) == dict 222 | 223 | if not db_id: 224 | if not db_name: 225 | raise ValueError('Either the name or id of the DB needs to be provided.') 226 | db_id = self.get_item_id('database', db_name) 227 | 228 | return self.get("/api/database/{}".format(db_id), params=params) 229 | 230 | 231 | 232 | def get_table_metadata(self, table_name=None, table_id=None, db_name=None, db_id=None, params=None): 233 | 234 | if params: 235 | assert type(params) == dict 236 | 237 | if not table_id: 238 | if not table_name: 239 | raise ValueError('Either the name or id of the table needs to be provided.') 240 | table_id = self.get_item_id('table', table_name, db_name=db_name, db_id=db_id) 241 | 242 | return self.get("/api/table/{}/query_metadata".format(table_id), params=params) 243 | 244 | 245 | 246 | def get_columns_name_id(self, table_name=None, db_name=None, table_id=None, db_id=None, verbose=False, column_id_name=False): 247 | ''' 248 | Return a dictionary with col_name key and col_id value, for the given table_id/table_name in the given db_id/db_name. 249 | If column_id_name is True, return a dictionary with col_id key and col_name value. 250 | ''' 251 | if not self.friendly_names_is_disabled(): 252 | raise ValueError('Please disable "Friendly Table and Field Names" from Admin Panel > Settings > General, and try again.') 253 | 254 | if not table_name: 255 | if not table_id: 256 | raise ValueError('Either the name or id of the table must be provided.') 257 | table_name = self.get_item_name(item_type='table', item_id=table_id) 258 | 259 | # Get db_id 260 | if not db_id: 261 | if db_name: 262 | db_id = self.get_item_id('database', db_name) 263 | else: 264 | if not table_id: 265 | table_id = self.get_item_id('table', table_name) 266 | db_id = self.get_db_id_from_table_id(table_id) 267 | 268 | # Get column names and IDs 269 | if column_id_name: 270 | return {i['id']: i['name'] for i in self.get("/api/database/{}/fields".format(db_id)) 271 | if i['table_name'] == table_name} 272 | else: 273 | return {i['name']: i['id'] for i in self.get("/api/database/{}/fields".format(db_id)) 274 | if i['table_name'] == table_name} 275 | 276 | 277 | 278 | def friendly_names_is_disabled(self): 279 | ''' 280 | The endpoint /api/database/:db-id/fields which is used in the function get_columns_name_id relies on the display name of fields. 281 | If "Friendly Table and Field Names" (in Admin Panel > Settings > General) is not disabled, it changes the display name of fields. 282 | So it is important to make sure this setting is disabled, before running the get_columns_name_id function. 283 | ''' 284 | # checking whether friendly_name is disabled required admin access. 285 | # So to let non-admin users also use this package we skip this step for them. 286 | # There is warning in the __init__ method for these users. 287 | if not self.is_admin: 288 | return True 289 | 290 | friendly_name_setting = [ i['value'] for i in self.get('/api/setting') if i['key'] == 'humanization-strategy' ][0] 291 | return friendly_name_setting == 'none' # 'none' means disabled 292 | 293 | 294 | 295 | @staticmethod 296 | def verbose_print(verbose, msg): 297 | if verbose: 298 | print(msg) 299 | -------------------------------------------------------------------------------- /metabase_api/_helper_methods_async.py: -------------------------------------------------------------------------------- 1 | 2 | async def get_item_info(self, item_type, item_id=None, item_name=None, 3 | collection_id=None, collection_name=None, 4 | params=None): 5 | """Async version of get_item_info""" 6 | assert item_type in ['database', 'table', 'card', 'collection', 'dashboard', 'pulse', 'segment'] 7 | 8 | if params: 9 | assert type(params) == dict 10 | 11 | if not item_id: 12 | if not item_name: 13 | raise ValueError(f'Either the name or id of the {item_type} must be provided.') 14 | item_id = await self.get_item_id(item_type, item_name, collection_id=collection_id, collection_name=collection_name) 15 | 16 | res = await self.get(f"/api/{item_type}/{item_id}", params=params) 17 | if res: 18 | return res 19 | else: 20 | raise ValueError(f'There is no {item_type} with the id "{item_id}"') 21 | 22 | 23 | 24 | async def get_item_name(self, item_type, item_id): 25 | """Async version of get_item_name""" 26 | assert item_type in ['database', 'table', 'card', 'collection', 'dashboard', 'pulse', 'segment'] 27 | 28 | res = await self.get(f"/api/{item_type}/{item_id}") 29 | if res: 30 | return res['name'] 31 | else: 32 | raise ValueError(f'There is no {item_type} with the id "{item_id}"') 33 | 34 | 35 | 36 | async def get_item_id(self, item_type, item_name, collection_id=None, collection_name=None, db_id=None, db_name=None, table_id=None): 37 | """Async version of get_item_id""" 38 | assert item_type in ['database', 'table', 'card', 'collection', 'dashboard', 'pulse', 'segment'] 39 | 40 | if item_type in ['card', 'dashboard', 'pulse']: 41 | if not collection_id: 42 | if not collection_name: 43 | # Collection name/id is not provided. Searching in all collections 44 | items = await self.get(f"/api/{item_type}/") 45 | item_IDs = [i['id'] for i in items if i['name'] == item_name and i['archived'] == False] 46 | else: 47 | collection_id = await self.get_item_id('collection', collection_name) if collection_name != 'root' else None 48 | items = await self.get(f"/api/{item_type}/") 49 | item_IDs = [i['id'] for i in items if i['name'] == item_name 50 | and i['collection_id'] == collection_id 51 | and i['archived'] == False] 52 | else: 53 | collection_name = await self.get_item_name('collection', collection_id) 54 | items = await self.get(f"/api/{item_type}/") 55 | item_IDs = [i['id'] for i in items if i['name'] == item_name 56 | and i['collection_id'] == collection_id 57 | and i['archived'] == False] 58 | 59 | if len(item_IDs) > 1: 60 | if not collection_name: 61 | raise ValueError(f'There is more than one {item_type} with the name "{item_name}".\n\ 62 | Provide collection id/name to limit the search space') 63 | raise ValueError(f'There is more than one {item_type} with the name "{item_name}" in the collection "{collection_name}"') 64 | if len(item_IDs) == 0: 65 | if not collection_name: 66 | raise ValueError(f'There is no {item_type} with the name "{item_name}"') 67 | raise ValueError(f'There is no item with the name "{item_name}" in the collection "{collection_name}"') 68 | 69 | return item_IDs[0] 70 | 71 | if item_type == 'collection': 72 | collections = await self.get("/api/collection/") 73 | collection_IDs = [i['id'] for i in collections if i['name'] == item_name] 74 | 75 | if len(collection_IDs) > 1: 76 | raise ValueError(f'There is more than one collection with the name "{item_name}"') 77 | if len(collection_IDs) == 0: 78 | raise ValueError(f'There is no collection with the name "{item_name}"') 79 | 80 | return collection_IDs[0] 81 | 82 | if item_type == 'database': 83 | res = await self.get("/api/database/") 84 | if type(res) == dict: # in Metabase version *.40.0 the format of the returned result for this endpoint changed 85 | res = res['data'] 86 | db_IDs = [i['id'] for i in res if i['name'] == item_name] 87 | 88 | if len(db_IDs) > 1: 89 | raise ValueError(f'There is more than one DB with the name "{item_name}"') 90 | if len(db_IDs) == 0: 91 | raise ValueError(f'There is no DB with the name "{item_name}"') 92 | 93 | return db_IDs[0] 94 | 95 | if item_type == 'table': 96 | tables = await self.get("/api/table/") 97 | 98 | if db_id: 99 | table_IDs = [i['id'] for i in tables if i['name'] == item_name and i['db']['id'] == db_id] 100 | elif db_name: 101 | table_IDs = [i['id'] for i in tables if i['name'] == item_name and i['db']['name'] == db_name] 102 | else: 103 | table_IDs = [i['id'] for i in tables if i['name'] == item_name] 104 | 105 | if len(table_IDs) > 1: 106 | raise ValueError(f'There is more than one table with the name {item_name}. Provide db id/name.') 107 | if len(table_IDs) == 0: 108 | raise ValueError(f'There is no table with the name "{item_name}" (in the provided db, if any)') 109 | 110 | return table_IDs[0] 111 | 112 | if item_type == 'segment': 113 | segments = await self.get("/api/segment/") 114 | segment_IDs = [i['id'] for i in segments if i['name'] == item_name and (not table_id or i['table_id'] == table_id)] 115 | 116 | if len(segment_IDs) > 1: 117 | raise ValueError(f'There is more than one segment with the name "{item_name}"') 118 | if len(segment_IDs) == 0: 119 | raise ValueError(f'There is no segment with the name "{item_name}"') 120 | 121 | return segment_IDs[0] 122 | 123 | 124 | async def get_db_id_from_table_id(self, table_id): 125 | """Async version of get_db_id_from_table_id""" 126 | tables = await self.get("/api/table/") 127 | tables_filtered = [i['db_id'] for i in tables if i['id'] == table_id] 128 | 129 | if len(tables_filtered) == 0: 130 | raise ValueError(f'There is no DB containing the table with the ID "{table_id}"') 131 | 132 | return tables_filtered[0] 133 | 134 | 135 | async def get_db_info(self, db_name=None, db_id=None, params=None): 136 | """ 137 | Async version of get_db_info. 138 | Return Database info. Use 'params' for providing arguments. 139 | For example to include tables in the result, use: params={'include':'tables'} 140 | """ 141 | if params: 142 | assert type(params) == dict 143 | 144 | if not db_id: 145 | if not db_name: 146 | raise ValueError('Either the name or id of the DB needs to be provided.') 147 | db_id = await self.get_item_id('database', db_name) 148 | 149 | return await self.get(f"/api/database/{db_id}", params=params) 150 | 151 | 152 | async def get_table_metadata(self, table_name=None, table_id=None, db_name=None, db_id=None, params=None): 153 | """Async version of get_table_metadata""" 154 | if params: 155 | assert type(params) == dict 156 | 157 | if not table_id: 158 | if not table_name: 159 | raise ValueError('Either the name or id of the table needs to be provided.') 160 | table_id = await self.get_item_id('table', table_name, db_name=db_name, db_id=db_id) 161 | 162 | return await self.get(f"/api/table/{table_id}/query_metadata", params=params) 163 | 164 | 165 | async def get_columns_name_id(self, table_name=None, db_name=None, table_id=None, db_id=None, verbose=False, column_id_name=False): 166 | """ 167 | Async version of get_columns_name_id. 168 | Return a dictionary with col_name key and col_id value, for the given table_id/table_name in the given db_id/db_name. 169 | If column_id_name is True, return a dictionary with col_id key and col_name value. 170 | """ 171 | if not await self.friendly_names_is_disabled(): 172 | raise ValueError('Please disable "Friendly Table and Field Names" from Admin Panel > Settings > General, and try again.') 173 | 174 | if not table_name: 175 | if not table_id: 176 | raise ValueError('Either the name or id of the table must be provided.') 177 | table_name = await self.get_item_name(item_type='table', item_id=table_id) 178 | 179 | # Get db_id 180 | if not db_id: 181 | if db_name: 182 | db_id = await self.get_item_id('database', db_name) 183 | else: 184 | if not table_id: 185 | table_id = await self.get_item_id('table', table_name) 186 | db_id = await self.get_db_id_from_table_id(table_id) 187 | 188 | # Get column names and IDs 189 | fields = await self.get(f"/api/database/{db_id}/fields") 190 | if column_id_name: 191 | return {i['id']: i['name'] for i in fields if i['table_name'] == table_name} 192 | else: 193 | return {i['name']: i['id'] for i in fields if i['table_name'] == table_name} 194 | 195 | 196 | async def friendly_names_is_disabled(self): 197 | """ 198 | Async version of friendly_names_is_disabled. 199 | The endpoint /api/database/:db-id/fields which is used in the function get_columns_name_id relies on the display name of fields. 200 | If "Friendly Table and Field Names" (in Admin Panel > Settings > General) is not disabled, it changes the display name of fields. 201 | So it is important to make sure this setting is disabled, before running the get_columns_name_id function. 202 | """ 203 | # checking whether friendly_name is disabled required admin access. 204 | # So to let non-admin users also use this package we skip this step for them. 205 | # There is warning in the __init__ method for these users. 206 | if not self.is_admin: 207 | return True 208 | 209 | settings = await self.get('/api/setting') 210 | friendly_name_setting = [i['value'] for i in settings if i['key'] == 'humanization-strategy'][0] 211 | return friendly_name_setting == 'none' # 'none' means disabled 212 | 213 | 214 | @staticmethod 215 | def verbose_print(verbose, msg): 216 | """Same as the synchronous version - no need for async here""" 217 | if verbose: 218 | print(msg) 219 | -------------------------------------------------------------------------------- /metabase_api/_rest_methods.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | def get(self, endpoint, *args, **kwargs): 4 | self.validate_session() 5 | res = requests.get(self.domain + endpoint, headers=self.header, **kwargs, auth=self.auth) 6 | if 'raw' in args: 7 | return res 8 | else: 9 | return res.json() if res.ok else False 10 | 11 | 12 | def post(self, endpoint, *args, **kwargs): 13 | self.validate_session() 14 | res = requests.post(self.domain + endpoint, headers=self.header, **kwargs, auth=self.auth) 15 | if 'raw' in args: 16 | return res 17 | else: 18 | return res.json() if res.ok else False 19 | 20 | 21 | def put(self, endpoint, *args, **kwargs): 22 | """Used for updating objects (cards, dashboards, ...)""" 23 | self.validate_session() 24 | res = requests.put(self.domain + endpoint, headers=self.header, **kwargs, auth=self.auth) 25 | if 'raw' in args: 26 | return res 27 | else: 28 | return res.status_code 29 | 30 | 31 | def delete(self, endpoint, *args, **kwargs): 32 | self.validate_session() 33 | res = requests.delete(self.domain + endpoint, headers=self.header, **kwargs, auth=self.auth) 34 | if 'raw' in args: 35 | return res 36 | else: 37 | return res.status_code -------------------------------------------------------------------------------- /metabase_api/_rest_methods_async.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | 3 | async def get(self, endpoint, *args, **kwargs): 4 | """Async version of GET request""" 5 | await self.validate_session_async() 6 | auth = (self.email, self.password) if self.auth else None 7 | 8 | async with httpx.AsyncClient(timeout=self.timeout) as client: 9 | res = await client.get( 10 | self.domain + endpoint, 11 | headers=self.header, 12 | auth=auth, 13 | **kwargs 14 | ) 15 | if 'raw' in args: 16 | return res 17 | else: 18 | return res.json() if res.status_code == 200 else False 19 | 20 | async def post(self, endpoint, *args, **kwargs): 21 | """Async version of POST request""" 22 | await self.validate_session_async() 23 | auth = (self.email, self.password) if self.auth else None 24 | 25 | async with httpx.AsyncClient(timeout=self.timeout) as client: 26 | res = await client.post( 27 | self.domain + endpoint, 28 | headers=self.header, 29 | auth=auth, 30 | **kwargs 31 | ) 32 | if 'raw' in args: 33 | return res 34 | else: 35 | return res.json() if res.status_code == 200 else False 36 | 37 | async def put(self, endpoint, *args, **kwargs): 38 | """Async version of PUT request for updating objects""" 39 | await self.validate_session_async() 40 | auth = (self.email, self.password) if self.auth else None 41 | 42 | async with httpx.AsyncClient(timeout=self.timeout) as client: 43 | res = await client.put( 44 | self.domain + endpoint, 45 | headers=self.header, 46 | auth=auth, 47 | **kwargs 48 | ) 49 | if 'raw' in args: 50 | return res 51 | else: 52 | return res.status_code 53 | 54 | async def delete(self, endpoint, *args, **kwargs): 55 | """Async version of DELETE request""" 56 | await self.validate_session_async() 57 | auth = (self.email, self.password) if self.auth else None 58 | 59 | async with httpx.AsyncClient(timeout=self.timeout) as client: 60 | res = await client.delete( 61 | self.domain + endpoint, 62 | headers=self.header, 63 | auth=auth, 64 | **kwargs 65 | ) 66 | if 'raw' in args: 67 | return res 68 | else: 69 | return res.status_code 70 | -------------------------------------------------------------------------------- /metabase_api/copy_methods.py: -------------------------------------------------------------------------------- 1 | 2 | def copy_card(self, source_card_name=None, source_card_id=None, 3 | source_collection_name=None, source_collection_id=None, 4 | destination_card_name=None, 5 | destination_collection_name=None, destination_collection_id=None, 6 | postfix='', verbose=False, return_card=False): 7 | """ 8 | Copy the card with the given name/id to the given destination collection. 9 | 10 | Parameters 11 | ---------- 12 | source_card_name : name of the card to copy (default None) 13 | source_card_id : id of the card to copy (default None) 14 | source_collection_name : name of the collection the source card is located in (default None) 15 | source_collection_id : id of the collection the source card is located in (default None) 16 | destination_card_name : name used for the card in destination (default None). 17 | If None, it will use the name of the source card + postfix. 18 | destination_collection_name : name of the collection to copy the card to (default None) 19 | destination_collection_id : id of the collection to copy the card to (default None) 20 | postfix : if destination_card_name is None, adds this string to the end of source_card_name 21 | to make destination_card_name 22 | """ 23 | ### Making sure we have the data that we need 24 | if not source_card_id: 25 | if not source_card_name: 26 | raise ValueError('Either the name or id of the source card must be provided.') 27 | else: 28 | source_card_id = self.get_item_id(item_type='card', 29 | item_name=source_card_name, 30 | collection_id=source_collection_id, 31 | collection_name=source_collection_name) 32 | 33 | if not destination_collection_id: 34 | if not destination_collection_name: 35 | raise ValueError('Either the name or id of the destination collection must be provided.') 36 | else: 37 | destination_collection_id = self.get_item_id('collection', destination_collection_name) 38 | 39 | if not destination_card_name: 40 | if not source_card_name: 41 | source_card_name = self.get_item_name(item_type='card', item_id=source_card_id) 42 | destination_card_name = source_card_name + postfix 43 | 44 | # Get the source card info 45 | source_card = self.get('/api/card/{}'.format(source_card_id)) 46 | 47 | # Update the name and collection_id 48 | card_json = source_card 49 | card_json['collection_id'] = destination_collection_id 50 | card_json['name'] = destination_card_name 51 | 52 | # Fix the issue #10 53 | if card_json.get('description') == '': 54 | card_json['description'] = None 55 | 56 | # Save as a new card 57 | res = self.create_card(custom_json=card_json, verbose=verbose, return_card=True) 58 | 59 | return res if return_card else res['id'] 60 | 61 | 62 | 63 | def copy_pulse(self, source_pulse_name=None, source_pulse_id=None, 64 | source_collection_name=None, source_collection_id=None, 65 | destination_pulse_name=None, 66 | destination_collection_id=None, destination_collection_name=None, postfix=''): 67 | """ 68 | Copy the pulse with the given name/id to the given destination collection. 69 | 70 | Parameters 71 | ---------- 72 | source_pulse_name : name of the pulse to copy (default None) 73 | source_pulse_id : id of the pulse to copy (default None) 74 | source_collection_name : name of the collection the source card is located in (default None) 75 | source_collection_id : id of the collection the source card is located in (default None) 76 | destination_pulse_name : name used for the pulse in destination (default None). 77 | If None, it will use the name of the source pulse + postfix. 78 | destination_collection_name : name of the collection to copy the pulse to (default None) 79 | destination_collection_id : id of the collection to copy the pulse to (default None) 80 | postfix : if destination_pulse_name is None, adds this string to the end of source_pulse_name 81 | to make destination_pulse_name 82 | """ 83 | ### Making sure we have the data that we need 84 | if not source_pulse_id: 85 | if not source_pulse_name: 86 | raise ValueError('Either the name or id of the source pulse must be provided.') 87 | else: 88 | source_pulse_id = self.get_item_id(item_type='pulse',item_name=source_pulse_name, 89 | collection_id=source_collection_id, 90 | collection_name=source_collection_name) 91 | 92 | if not destination_collection_id: 93 | if not destination_collection_name: 94 | raise ValueError('Either the name or id of the destination collection must be provided.') 95 | else: 96 | destination_collection_id = self.get_item_id('collection', destination_collection_name) 97 | 98 | if not destination_pulse_name: 99 | if not source_pulse_name: 100 | source_pulse_name = self.get_item_name(item_type='pulse', item_id=source_pulse_id) 101 | destination_pulse_name = source_pulse_name + postfix 102 | 103 | # Get the source pulse info 104 | source_pulse = self.get('/api/pulse/{}'.format(source_pulse_id)) 105 | 106 | # Updat the name and collection_id 107 | pulse_json = source_pulse 108 | pulse_json['collection_id'] = destination_collection_id 109 | pulse_json['name'] = destination_pulse_name 110 | 111 | # Save as a new pulse 112 | self.post('/api/pulse', json=pulse_json) 113 | 114 | 115 | 116 | def copy_dashboard(self, source_dashboard_name=None, source_dashboard_id=None, 117 | source_collection_name=None, source_collection_id=None, 118 | destination_dashboard_name=None, 119 | destination_collection_name=None, destination_collection_id=None, 120 | deepcopy=False, postfix='', collection_position=1, description=''): 121 | """ 122 | Copy the dashboard with the given name/id to the given destination collection. 123 | 124 | Parameters 125 | ---------- 126 | source_dashboard_name : name of the dashboard to copy (default None) 127 | source_dashboard_id : id of the dashboard to copy (default None) 128 | source_collection_name : name of the collection the source dashboard is located in (default None) 129 | source_collection_id : id of the collection the source dashboard is located in (default None) 130 | destination_dashboard_name : name used for the dashboard in destination (default None). 131 | If None, it will use the name of the source dashboard + postfix. 132 | destination_collection_name : name of the collection to copy the dashboard to (default None) 133 | destination_collection_id : id of the collection to copy the dashboard to (default None) 134 | deepcopy : whether to duplicate the cards inside the dashboard (default False). 135 | postfix : if destination_dashboard_name is None, adds this string to the end of source_dashboard_name 136 | to make destination_dashboard_name 137 | """ 138 | ### making sure we have the data that we need 139 | if not source_dashboard_id: 140 | if not source_dashboard_name: 141 | raise ValueError('Either the name or id of the source dashboard must be provided.') 142 | else: 143 | source_dashboard_id = self.get_item_id(item_type='dashboard',item_name=source_dashboard_name, 144 | collection_id=source_collection_id, 145 | collection_name=source_collection_name) 146 | 147 | if not destination_collection_id: 148 | if not destination_collection_name: 149 | raise ValueError('Either the name or id of the destination collection must be provided.') 150 | else: 151 | destination_collection_id = self.get_item_id('collection', destination_collection_name) 152 | 153 | if not destination_dashboard_name: 154 | if not source_dashboard_name: 155 | source_dashboard_name = self.get_item_name(item_type='dashboard', item_id=source_dashboard_id) 156 | destination_dashboard_name = source_dashboard_name + postfix 157 | 158 | parameters = { 159 | 'collection_id':destination_collection_id, 160 | 'name':destination_dashboard_name, 161 | 'is_deep_copy':deepcopy, 162 | 'collection_position': collection_position, 163 | 'description': description 164 | } 165 | res = self.post('/api/dashboard/{}/copy'.format(source_dashboard_id), 'raw', json=parameters) 166 | if res.status_code != 200: 167 | raise ValueError('Error copying the dashboard: {}'.format(res.text)) 168 | dup_dashboard_id = res.json()['id'] 169 | return dup_dashboard_id 170 | 171 | 172 | 173 | def copy_collection(self, source_collection_name=None, source_collection_id=None, 174 | destination_collection_name=None, 175 | destination_parent_collection_name=None, destination_parent_collection_id=None, 176 | deepcopy_dashboards=False, postfix='', child_items_postfix='', verbose=False): 177 | """ 178 | Copy the collection with the given name/id into the given destination parent collection. 179 | 180 | Parameters 181 | ---------- 182 | source_collection_name : name of the collection to copy (default None) 183 | source_collection_id : id of the collection to copy (default None) 184 | destination_collection_name : the name to be used for the collection in the destination (default None). 185 | If None, it will use the name of the source collection + postfix. 186 | destination_parent_collection_name : name of the destination parent collection (default None). 187 | This is the collection that would have the copied collection as a child. 188 | use 'Root' for the root collection. 189 | destination_parent_collection_id : id of the destination parent collection (default None). 190 | This is the collection that would have the copied collection as a child. 191 | deepcopy_dashboards : whether to duplicate the cards inside the dashboards (default False). 192 | If True, puts the duplicated cards in a collection called "[dashboard_name]'s duplicated cards" 193 | in the same path as the duplicated dashboard. 194 | postfix : if destination_collection_name is None, adds this string to the end of source_collection_name to make destination_collection_name. 195 | child_items_postfix : this string is added to the end of the child items' names, when saving them in the destination (default ''). 196 | verbose : prints extra information (default False) 197 | """ 198 | ### making sure we have the data that we need 199 | if not source_collection_id: 200 | if not source_collection_name: 201 | raise ValueError('Either the name or id of the source collection must be provided.') 202 | else: 203 | source_collection_id = self.get_item_id('collection', source_collection_name) 204 | 205 | if not destination_parent_collection_id: 206 | if not destination_parent_collection_name: 207 | raise ValueError('Either the name or id of the destination parent collection must be provided.') 208 | else: 209 | destination_parent_collection_id = ( 210 | self.get_item_id('collection', destination_parent_collection_name) 211 | if destination_parent_collection_name != 'Root' 212 | else None 213 | ) 214 | 215 | if not destination_collection_name: 216 | if not source_collection_name: 217 | source_collection_name = self.get_item_name(item_type='collection', item_id=source_collection_id) 218 | destination_collection_name = source_collection_name + postfix 219 | 220 | ### create a collection in the destination to hold the contents of the source collection 221 | res = self.create_collection(destination_collection_name, 222 | parent_collection_id=destination_parent_collection_id, 223 | parent_collection_name=destination_parent_collection_name, 224 | return_results=True 225 | ) 226 | destination_collection_id = res['id'] 227 | 228 | ### get the items to copy 229 | items = self.get('/api/collection/{}/items'.format(source_collection_id)) 230 | if type(items) == dict: # in Metabase version *.40.0 the format of the returned result for this endpoint changed 231 | items = items['data'] 232 | 233 | ### copy the items of the source collection to the new collection 234 | for item in items: 235 | 236 | ## copy a collection 237 | if item['model'] == 'collection': 238 | collection_id = item['id'] 239 | collection_name = item['name'] 240 | destination_collection_name = collection_name + child_items_postfix 241 | self.verbose_print(verbose, 'Copying the collection "{}" ...'.format(collection_name)) 242 | self.copy_collection(source_collection_id=collection_id, 243 | destination_parent_collection_id=destination_collection_id, 244 | child_items_postfix=child_items_postfix, 245 | deepcopy_dashboards=deepcopy_dashboards, 246 | verbose=verbose) 247 | 248 | ## copy a dashboard 249 | if item['model'] == 'dashboard': 250 | dashboard_id = item['id'] 251 | dashboard_name = item['name'] 252 | destination_dashboard_name = dashboard_name + child_items_postfix 253 | self.verbose_print(verbose, 'Copying the dashboard "{}" ...'.format(dashboard_name)) 254 | self.copy_dashboard(source_dashboard_id=dashboard_id, 255 | destination_collection_id=destination_collection_id, 256 | destination_dashboard_name=destination_dashboard_name, 257 | deepcopy=deepcopy_dashboards) 258 | 259 | ## copy a card 260 | if item['model'] == 'card': 261 | card_id = item['id'] 262 | card_name = item['name'] 263 | destination_card_name = card_name + child_items_postfix 264 | self.verbose_print(verbose, 'Copying the card "{}" ...'.format(card_name)) 265 | self.copy_card(source_card_id=card_id, 266 | destination_collection_id=destination_collection_id, 267 | destination_card_name=destination_card_name) 268 | 269 | ## copy a pulse 270 | if item['model'] == 'pulse': 271 | pulse_id = item['id'] 272 | pulse_name = item['name'] 273 | destination_pulse_name = pulse_name + child_items_postfix 274 | self.verbose_print(verbose, 'Copying the pulse "{}" ...'.format(pulse_name)) 275 | self.copy_pulse(source_pulse_id=pulse_id, 276 | destination_collection_id=destination_collection_id, 277 | destination_pulse_name=destination_pulse_name) 278 | 279 | 280 | -------------------------------------------------------------------------------- /metabase_api/copy_methods_async.py: -------------------------------------------------------------------------------- 1 | 2 | async def copy_card(self, source_card_name=None, source_card_id=None, 3 | source_collection_name=None, source_collection_id=None, 4 | destination_card_name=None, 5 | destination_collection_name=None, destination_collection_id=None, 6 | postfix='', verbose=False, return_card=False): 7 | """ 8 | Async version of copy_card. 9 | Copy the card with the given name/id to the given destination collection. 10 | """ 11 | # Making sure we have the data that we need 12 | if not source_card_id: 13 | if not source_card_name: 14 | raise ValueError('Either the name or id of the source card must be provided.') 15 | else: 16 | source_card_id = await self.get_item_id(item_type='card', 17 | item_name=source_card_name, 18 | collection_id=source_collection_id, 19 | collection_name=source_collection_name) 20 | 21 | if not destination_collection_id: 22 | if not destination_collection_name: 23 | raise ValueError('Either the name or id of the destination collection must be provided.') 24 | else: 25 | destination_collection_id = await self.get_item_id('collection', destination_collection_name) 26 | 27 | if not destination_card_name: 28 | if not source_card_name: 29 | source_card_name = await self.get_item_name(item_type='card', item_id=source_card_id) 30 | destination_card_name = source_card_name + postfix 31 | 32 | # Get the source card info 33 | source_card = await self.get(f'/api/card/{source_card_id}') 34 | 35 | # Update the name and collection_id 36 | card_json = source_card 37 | card_json['collection_id'] = destination_collection_id 38 | card_json['name'] = destination_card_name 39 | 40 | # Fix the issue #10 41 | if card_json.get('description') == '': 42 | card_json['description'] = None 43 | 44 | # Save as a new card 45 | res = await self.create_card(custom_json=card_json, verbose=verbose, return_card=True) 46 | 47 | return res if return_card else res['id'] 48 | 49 | 50 | async def copy_pulse(self, source_pulse_name=None, source_pulse_id=None, 51 | source_collection_name=None, source_collection_id=None, 52 | destination_pulse_name=None, 53 | destination_collection_id=None, destination_collection_name=None, postfix=''): 54 | """ 55 | Async version of copy_pulse. 56 | Copy the pulse with the given name/id to the given destination collection. 57 | """ 58 | # Making sure we have the data that we need 59 | if not source_pulse_id: 60 | if not source_pulse_name: 61 | raise ValueError('Either the name or id of the source pulse must be provided.') 62 | else: 63 | source_pulse_id = await self.get_item_id(item_type='pulse', item_name=source_pulse_name, 64 | collection_id=source_collection_id, 65 | collection_name=source_collection_name) 66 | 67 | if not destination_collection_id: 68 | if not destination_collection_name: 69 | raise ValueError('Either the name or id of the destination collection must be provided.') 70 | else: 71 | destination_collection_id = await self.get_item_id('collection', destination_collection_name) 72 | 73 | if not destination_pulse_name: 74 | if not source_pulse_name: 75 | source_pulse_name = await self.get_item_name(item_type='pulse', item_id=source_pulse_id) 76 | destination_pulse_name = source_pulse_name + postfix 77 | 78 | # Get the source pulse info 79 | source_pulse = await self.get(f'/api/pulse/{source_pulse_id}') 80 | 81 | # Update the name and collection_id 82 | pulse_json = source_pulse 83 | pulse_json['collection_id'] = destination_collection_id 84 | pulse_json['name'] = destination_pulse_name 85 | 86 | # Save as a new pulse 87 | await self.post('/api/pulse', json=pulse_json) 88 | 89 | 90 | async def copy_dashboard(self, source_dashboard_name=None, source_dashboard_id=None, 91 | source_collection_name=None, source_collection_id=None, 92 | destination_dashboard_name=None, 93 | destination_collection_name=None, destination_collection_id=None, 94 | deepcopy=False, postfix='', collection_position=1, description=''): 95 | """ 96 | Async version of copy_dashboard. 97 | Copy the dashboard with the given name/id to the given destination collection. 98 | """ 99 | # Making sure we have the data that we need 100 | if not source_dashboard_id: 101 | if not source_dashboard_name: 102 | raise ValueError('Either the name or id of the source dashboard must be provided.') 103 | else: 104 | source_dashboard_id = await self.get_item_id(item_type='dashboard', item_name=source_dashboard_name, 105 | collection_id=source_collection_id, 106 | collection_name=source_collection_name) 107 | 108 | if not destination_collection_id: 109 | if not destination_collection_name: 110 | raise ValueError('Either the name or id of the destination collection must be provided.') 111 | else: 112 | destination_collection_id = await self.get_item_id('collection', destination_collection_name) 113 | 114 | if not destination_dashboard_name: 115 | if not source_dashboard_name: 116 | source_dashboard_name = await self.get_item_name(item_type='dashboard', item_id=source_dashboard_id) 117 | destination_dashboard_name = source_dashboard_name + postfix 118 | 119 | parameters = { 120 | 'collection_id': destination_collection_id, 121 | 'name': destination_dashboard_name, 122 | 'is_deep_copy': deepcopy, 123 | 'collection_position': collection_position, 124 | 'description': description 125 | } 126 | 127 | res = await self.post(f'/api/dashboard/{source_dashboard_id}/copy', 'raw', json=parameters) 128 | if res.status != 200: 129 | raise ValueError(f'Error copying the dashboard: {await res.text()}') 130 | 131 | data = await res.json() 132 | dup_dashboard_id = data['id'] 133 | return dup_dashboard_id 134 | 135 | 136 | async def copy_collection(self, source_collection_name=None, source_collection_id=None, 137 | destination_collection_name=None, 138 | destination_parent_collection_name=None, destination_parent_collection_id=None, 139 | deepcopy_dashboards=False, postfix='', child_items_postfix='', verbose=False): 140 | """ 141 | Async version of copy_collection. 142 | Copy the collection with the given name/id into the given destination parent collection. 143 | """ 144 | # Making sure we have the data that we need 145 | if not source_collection_id: 146 | if not source_collection_name: 147 | raise ValueError('Either the name or id of the source collection must be provided.') 148 | else: 149 | source_collection_id = await self.get_item_id('collection', source_collection_name) 150 | 151 | if not destination_parent_collection_id: 152 | if not destination_parent_collection_name: 153 | raise ValueError('Either the name or id of the destination parent collection must be provided.') 154 | else: 155 | destination_parent_collection_id = ( 156 | await self.get_item_id('collection', destination_parent_collection_name) 157 | if destination_parent_collection_name != 'Root' 158 | else None 159 | ) 160 | 161 | if not destination_collection_name: 162 | if not source_collection_name: 163 | source_collection_name = await self.get_item_name(item_type='collection', item_id=source_collection_id) 164 | destination_collection_name = source_collection_name + postfix 165 | 166 | # Create a collection in the destination to hold the contents of the source collection 167 | res = await self.create_collection( 168 | destination_collection_name, 169 | parent_collection_id=destination_parent_collection_id, 170 | parent_collection_name=destination_parent_collection_name, 171 | return_results=True 172 | ) 173 | destination_collection_id = res['id'] 174 | 175 | # Get the items to copy 176 | items = await self.get(f'/api/collection/{source_collection_id}/items') 177 | if type(items) == dict: # in Metabase version *.40.0 the format of the returned result for this endpoint changed 178 | items = items['data'] 179 | 180 | # Copy the items of the source collection to the new collection 181 | for item in items: 182 | # Copy a collection 183 | if item['model'] == 'collection': 184 | collection_id = item['id'] 185 | collection_name = item['name'] 186 | destination_collection_name = collection_name + child_items_postfix 187 | self.verbose_print(verbose, f'Copying the collection "{collection_name}" ...') 188 | await self.copy_collection( 189 | source_collection_id=collection_id, 190 | destination_parent_collection_id=destination_collection_id, 191 | child_items_postfix=child_items_postfix, 192 | deepcopy_dashboards=deepcopy_dashboards, 193 | verbose=verbose 194 | ) 195 | 196 | # Copy a dashboard 197 | if item['model'] == 'dashboard': 198 | dashboard_id = item['id'] 199 | dashboard_name = item['name'] 200 | destination_dashboard_name = dashboard_name + child_items_postfix 201 | self.verbose_print(verbose, f'Copying the dashboard "{dashboard_name}" ...') 202 | await self.copy_dashboard( 203 | source_dashboard_id=dashboard_id, 204 | destination_collection_id=destination_collection_id, 205 | destination_dashboard_name=destination_dashboard_name, 206 | deepcopy=deepcopy_dashboards 207 | ) 208 | 209 | # Copy a card 210 | if item['model'] == 'card': 211 | card_id = item['id'] 212 | card_name = item['name'] 213 | destination_card_name = card_name + child_items_postfix 214 | self.verbose_print(verbose, f'Copying the card "{card_name}" ...') 215 | await self.copy_card( 216 | source_card_id=card_id, 217 | destination_collection_id=destination_collection_id, 218 | destination_card_name=destination_card_name 219 | ) 220 | 221 | # Copy a pulse 222 | if item['model'] == 'pulse': 223 | pulse_id = item['id'] 224 | pulse_name = item['name'] 225 | destination_pulse_name = pulse_name + child_items_postfix 226 | self.verbose_print(verbose, f'Copying the pulse "{pulse_name}" ...') 227 | await self.copy_pulse( 228 | source_pulse_id=pulse_id, 229 | destination_collection_id=destination_collection_id, 230 | destination_pulse_name=destination_pulse_name 231 | ) 232 | -------------------------------------------------------------------------------- /metabase_api/create_methods.py: -------------------------------------------------------------------------------- 1 | 2 | def create_card(self, card_name=None, collection_name=None, collection_id=None, 3 | db_name=None, db_id=None, table_name=None, table_id=None, 4 | column_order='db_table_order', custom_json=None, verbose=False, return_card=False): 5 | """ 6 | Create a card using the given arguments utilizing the endpoint 'POST /api/card/'. 7 | If collection is not given, the root collection is used. 8 | 9 | Parameters 10 | ---------- 11 | card_name : the name used to create the card (default None) 12 | collection_name : name of the collection to place the card (default None). 13 | collection_id : id of the collection to place the card (default None) 14 | db_name : name of the db that is used as the source of data (default None) 15 | db_id : id of the db used as the source of data (default None) 16 | table_name : name of the table used as the source of data (default None) 17 | table_id : id of the table used as the source of data (default None) 18 | column_order : order for showing columns. Accepted values are 'alphabetical', 'db_table_order' (default) 19 | or a list of column names 20 | custom_json : key-value pairs that can provide some or all the data needed for creating the card (default None). 21 | If you are providing only this argument, the keys 'name', 'dataset_query' and 'display' are required 22 | (https://github.com/metabase/metabase/blob/master/docs/api-documentation.md#post-apicard). 23 | verbose : whether to print extra information (default False) 24 | return_card : whather to return the created card info (default False) 25 | """ 26 | if custom_json: 27 | assert type(custom_json) == dict 28 | # Check whether the provided json has the required info or not 29 | complete_json = True 30 | for item in ['name', 'dataset_query', 'display']: 31 | if item not in custom_json: 32 | complete_json = False 33 | self.verbose_print(verbose, 'The provided json is detected as partial.') 34 | break 35 | 36 | # Fix for the issue #10 37 | if custom_json.get('description') == '': 38 | custom_json['description'] = None 39 | 40 | # Set the collection 41 | if collection_id: 42 | custom_json['collection_id'] = collection_id 43 | elif collection_name: 44 | collection_id = self.get_item_id('collection', collection_name) 45 | custom_json['collection_id'] = collection_id 46 | 47 | if complete_json: 48 | # Add visualization_settings if it is not present in the custom_json 49 | if 'visualization_settings' not in custom_json: 50 | custom_json['visualization_settings'] = {} 51 | # Add the card name if it is provided 52 | if card_name is not None: 53 | custom_json['name'] = card_name 54 | if collection_id: 55 | custom_json['collection_id'] = collection_id 56 | elif collection_name: 57 | collection_id = self.get_item_id('collection', collection_name) 58 | custom_json['collection_id'] = collection_id 59 | if not custom_json.get('collection_id'): 60 | self.verbose_print(verbose, 'No collection name or id is provided. Will create the card at the root ...') 61 | 62 | # Create the card using only the provided custom_json 63 | res = self.post("/api/card/", json=custom_json) 64 | if res and not res.get('error'): 65 | self.verbose_print(verbose, 'The card was created successfully.') 66 | return res if return_card else None 67 | else: 68 | print('Card Creation Failed.\n', res) 69 | return res 70 | 71 | # Making sure we have the required data 72 | if not card_name and (not custom_json or not custom_json.get('name')): 73 | raise ValueError("A name must be provided for the card (either as card_name argument or as part of the custom_json ('name' key)).") 74 | if not table_id: 75 | if not table_name: 76 | raise ValueError('Either the name or id of the table must be provided.') 77 | table_id = self.get_item_id('table', table_name, db_id=db_id, db_name=db_name) 78 | if not table_name: 79 | table_name = self.get_item_name(item_type='table', item_id=table_id) 80 | if not db_id: 81 | db_id = self.get_db_id_from_table_id(table_id) 82 | 83 | # Get collection_id if it is not given 84 | if not collection_id: 85 | if not collection_name: 86 | self.verbose_print(verbose, 'No collection name or id is provided. Will create the card at the root ...') 87 | else: 88 | collection_id = self.get_item_id('collection', collection_name) 89 | 90 | if type(column_order) == list: 91 | 92 | column_name_id_dict = self.get_columns_name_id( db_id=db_id, 93 | table_id=table_id, 94 | table_name=table_name, 95 | verbose=verbose) 96 | try: 97 | column_id_list = [column_name_id_dict[i] for i in column_order] 98 | except ValueError as e: 99 | print('The column name {} is not in the table {}. \nThe card creation failed!'.format(e, table_name)) 100 | return False 101 | 102 | column_id_list_str = [['field-id', i] for i in column_id_list] 103 | 104 | elif column_order == 'db_table_order': # default 105 | 106 | ### find the actual order of columns in the table as they appear in the database 107 | # Create a temporary card for retrieving column ordering 108 | json_str = """{{'dataset_query': {{ 'database': {1}, 109 | 'native': {{'query': 'SELECT * from "{2}";' }}, 110 | 'type': 'native' }}, 111 | 'display': 'table', 112 | 'name': '{0}', 113 | 'visualization_settings': {{}} }}""".format(card_name, db_id, table_name) 114 | 115 | res = self.post("/api/card/", json=eval(json_str)) 116 | if not res: 117 | print('Card Creation Failed!') 118 | return res 119 | ordered_columns = [ i['name'] for i in res['result_metadata'] ] # retrieving the column ordering 120 | 121 | # Delete the temporary card 122 | card_id = res['id'] 123 | self.delete("/api/card/{}".format(card_id)) 124 | 125 | column_name_id_dict = self.get_columns_name_id(db_id=db_id, 126 | table_id=table_id, 127 | table_name=table_name, 128 | verbose=verbose) 129 | column_id_list = [ column_name_id_dict[i] for i in ordered_columns ] 130 | column_id_list_str = [ ['field-id', i] for i in column_id_list ] 131 | 132 | elif column_order == 'alphabetical': 133 | column_id_list_str = None 134 | 135 | else: 136 | raise ValueError("Wrong value for 'column_order'. \ 137 | Accepted values: 'alphabetical', 'db_table_order' or a list of column names.") 138 | 139 | # default json 140 | json_str = """{{'dataset_query': {{'database': {1}, 141 | 'query': {{'fields': {4}, 142 | 'source-table': {2}}}, 143 | 'type': 'query'}}, 144 | 'display': 'table', 145 | 'name': '{0}', 146 | 'collection_id': {3}, 147 | 'visualization_settings': {{}} 148 | }}""".format(card_name, db_id, table_id, collection_id, column_id_list_str) 149 | json = eval(json_str) 150 | 151 | # Add/Rewrite data to the default json from custom_json 152 | if custom_json: 153 | for key, value in custom_json.items(): 154 | if key in ['name', 'dataset_query', 'display']: 155 | self.verbose_print(verbose, "Ignored '{}' key in the provided custom_json.".format(key)) 156 | continue 157 | json[key] = value 158 | 159 | res = self.post("/api/card/", json=json) 160 | 161 | # Get collection_name to be used in the final message 162 | if not collection_name: 163 | if not collection_id: 164 | collection_name = 'root' 165 | else: 166 | collection_name = self.get_item_name(item_type='collection', item_id=collection_id) 167 | 168 | if res and not res.get('error'): 169 | self.verbose_print(verbose, "The card '{}' was created successfully in the collection '{}'." 170 | .format(card_name, collection_name)) 171 | if return_card: return res 172 | else: 173 | print('Card Creation Failed.\n', res) 174 | return res 175 | 176 | 177 | 178 | def create_collection(self, collection_name, parent_collection_id=None, parent_collection_name=None, return_results=False): 179 | """ 180 | Create an empty collection, in the given location, utilizing the endpoint 'POST /api/collection/'. 181 | 182 | Parameters 183 | ---------- 184 | collection_name : the name used for the created collection. 185 | parent_collection_id : id of the collection where the created collection resides in. 186 | parent_collection_name : name of the collection where the created collection resides in (use 'Root' for the root collection). 187 | return_results : whether to return the info of the created collection. 188 | """ 189 | # Making sure we have the data we need 190 | if not parent_collection_id: 191 | if not parent_collection_name: 192 | print('Either the name of id of the parent collection must be provided.') 193 | if parent_collection_name == 'Root': 194 | parent_collection_id = None 195 | else: 196 | parent_collection_id = self.get_item_id('collection', parent_collection_name) 197 | 198 | res = self.post('/api/collection', json={'name':collection_name, 'parent_id':parent_collection_id, 'color':'#509EE3'}) 199 | if return_results: 200 | return res 201 | 202 | 203 | 204 | def create_segment(self, segment_name, column_name, column_values, segment_description='', 205 | db_name=None, db_id=None, table_name=None, table_id=None, return_segment=False): 206 | """ 207 | Create a segment using the given arguments utilizing the endpoint 'POST /api/segment/'. 208 | 209 | Parameters 210 | ---------- 211 | segment_name : the name used for the created segment. 212 | column_name : name of the column used for filtering. 213 | column_values : list of values for filtering in the given column. 214 | segment_description : description of the segment (default '') 215 | db_name : name of the db that is used as the source of data (default None) 216 | db_id : id of the db used as the source of data (default None) 217 | table_name : name of the table used for creating the segmnet on it (default None) 218 | table_id : id of the table used for creating the segmnet on it (default None) 219 | return_segment : whather to return the created segment info (default False) 220 | """ 221 | # Making sure we have the data needed 222 | if not table_name and not table_id: 223 | raise ValueError('Either the name or id of the table must be provided.') 224 | if not table_id: 225 | table_id = self.get_item_id('table', table_name, db_id=db_id, db_name=db_name) 226 | if not table_name: 227 | table_name = self.get_item_name(item_type='table', item_id=table_id) 228 | db_id = self.get_db_id_from_table_id(table_id) 229 | 230 | colmuns_name_id_mapping = self.get_columns_name_id(table_name=table_name, db_id=db_id) 231 | column_id = colmuns_name_id_mapping[column_name] 232 | 233 | # Create a segment blueprint 234 | segment_blueprint = {'name': segment_name, 235 | 'description': segment_description, 236 | 'table_id': table_id, 237 | 'definition': {'source-table': table_id, 'filter': ['=', ['field-id', column_id]]}} 238 | 239 | # Add filtering values 240 | segment_blueprint['definition']['filter'].extend(column_values) 241 | 242 | # Create the segment 243 | res = self.post('/api/segment/', json=segment_blueprint) 244 | if return_segment: 245 | return res 246 | 247 | 248 | -------------------------------------------------------------------------------- /metabase_api/create_methods_async.py: -------------------------------------------------------------------------------- 1 | 2 | async def create_card(self, card_name=None, collection_name=None, collection_id=None, 3 | db_name=None, db_id=None, table_name=None, table_id=None, 4 | column_order='db_table_order', custom_json=None, verbose=False, return_card=False): 5 | """ 6 | Async version of create_card. 7 | Create a card using the given arguments utilizing the endpoint 'POST /api/card/'. 8 | If collection is not given, the root collection is used. 9 | """ 10 | if custom_json: 11 | assert type(custom_json) == dict 12 | # Check whether the provided json has the required info or not 13 | complete_json = True 14 | for item in ['name', 'dataset_query', 'display']: 15 | if item not in custom_json: 16 | complete_json = False 17 | self.verbose_print(verbose, 'The provided json is detected as partial.') 18 | break 19 | 20 | # Fix for the issue #10 21 | if custom_json.get('description') == '': 22 | custom_json['description'] = None 23 | 24 | # Set the collection 25 | if collection_id: 26 | custom_json['collection_id'] = collection_id 27 | elif collection_name: 28 | collection_id = await self.get_item_id('collection', collection_name) 29 | custom_json['collection_id'] = collection_id 30 | 31 | if complete_json: 32 | # Add visualization_settings if it is not present in the custom_json 33 | if 'visualization_settings' not in custom_json: 34 | custom_json['visualization_settings'] = {} 35 | # Add the card name if it is provided 36 | if card_name is not None: 37 | custom_json['name'] = card_name 38 | if collection_id: 39 | custom_json['collection_id'] = collection_id 40 | elif collection_name: 41 | collection_id = await self.get_item_id('collection', collection_name) 42 | custom_json['collection_id'] = collection_id 43 | if not custom_json.get('collection_id'): 44 | self.verbose_print(verbose, 'No collection name or id is provided. Will create the card at the root ...') 45 | 46 | # Create the card using only the provided custom_json 47 | res = await self.post("/api/card/", json=custom_json) 48 | if res and not res.get('error'): 49 | self.verbose_print(verbose, 'The card was created successfully.') 50 | return res if return_card else None 51 | else: 52 | print('Card Creation Failed.\n', res) 53 | return res 54 | 55 | # Making sure we have the required data 56 | if not card_name and (not custom_json or not custom_json.get('name')): 57 | raise ValueError("A name must be provided for the card (either as card_name argument or as part of the custom_json ('name' key)).") 58 | if not table_id: 59 | if not table_name: 60 | raise ValueError('Either the name or id of the table must be provided.') 61 | table_id = await self.get_item_id('table', table_name, db_id=db_id, db_name=db_name) 62 | if not table_name: 63 | table_name = await self.get_item_name(item_type='table', item_id=table_id) 64 | if not db_id: 65 | db_id = await self.get_db_id_from_table_id(table_id) 66 | 67 | # Get collection_id if it is not given 68 | if not collection_id: 69 | if not collection_name: 70 | self.verbose_print(verbose, 'No collection name or id is provided. Will create the card at the root ...') 71 | else: 72 | collection_id = await self.get_item_id('collection', collection_name) 73 | 74 | if type(column_order) == list: 75 | column_name_id_dict = await self.get_columns_name_id( 76 | db_id=db_id, 77 | table_id=table_id, 78 | table_name=table_name, 79 | verbose=verbose 80 | ) 81 | try: 82 | column_id_list = [column_name_id_dict[i] for i in column_order] 83 | except ValueError as e: 84 | print(f'The column name {e} is not in the table {table_name}. \nThe card creation failed!') 85 | return False 86 | 87 | column_id_list_str = [['field-id', i] for i in column_id_list] 88 | 89 | elif column_order == 'db_table_order': # default 90 | # Find the actual order of columns in the table as they appear in the database 91 | # Create a temporary card for retrieving column ordering 92 | json_str = f"""{{ 93 | 'dataset_query': {{ 94 | 'database': {db_id}, 95 | 'native': {{'query': 'SELECT * from "{table_name}";' }}, 96 | 'type': 'native' 97 | }}, 98 | 'display': 'table', 99 | 'name': '{card_name}', 100 | 'visualization_settings': {{}} 101 | }}""" 102 | 103 | json_dict = eval(json_str.replace("'", '"')) 104 | res = await self.post("/api/card/", json=json_dict) 105 | if not res: 106 | print('Card Creation Failed!') 107 | return res 108 | 109 | ordered_columns = [i['name'] for i in res['result_metadata']] # retrieving the column ordering 110 | 111 | # Delete the temporary card 112 | card_id = res['id'] 113 | await self.delete(f"/api/card/{card_id}") 114 | 115 | column_name_id_dict = await self.get_columns_name_id( 116 | db_id=db_id, 117 | table_id=table_id, 118 | table_name=table_name, 119 | verbose=verbose 120 | ) 121 | column_id_list = [column_name_id_dict[i] for i in ordered_columns] 122 | column_id_list_str = [['field-id', i] for i in column_id_list] 123 | 124 | elif column_order == 'alphabetical': 125 | column_id_list_str = None 126 | 127 | else: 128 | raise ValueError("Wrong value for 'column_order'. \ 129 | Accepted values: 'alphabetical', 'db_table_order' or a list of column names.") 130 | 131 | # default json 132 | json_str = f"""{{ 133 | 'dataset_query': {{ 134 | 'database': {db_id}, 135 | 'query': {{ 136 | 'fields': {column_id_list_str}, 137 | 'source-table': {table_id} 138 | }}, 139 | 'type': 'query' 140 | }}, 141 | 'display': 'table', 142 | 'name': '{card_name}', 143 | 'collection_id': {collection_id if collection_id else 'null'}, 144 | 'visualization_settings': {{}} 145 | }}""" 146 | 147 | json_dict = eval(json_str.replace("'", '"')) 148 | 149 | # Add/Rewrite data to the default json from custom_json 150 | if custom_json: 151 | for key, value in custom_json.items(): 152 | if key in ['name', 'dataset_query', 'display']: 153 | self.verbose_print(verbose, f"Ignored '{key}' key in the provided custom_json.") 154 | continue 155 | json_dict[key] = value 156 | 157 | res = await self.post("/api/card/", json=json_dict) 158 | 159 | # Get collection_name to be used in the final message 160 | if not collection_name: 161 | if not collection_id: 162 | collection_name = 'root' 163 | else: 164 | collection_name = await self.get_item_name(item_type='collection', item_id=collection_id) 165 | 166 | if res and not res.get('error'): 167 | self.verbose_print(verbose, f"The card '{card_name}' was created successfully in the collection '{collection_name}'.") 168 | if return_card: 169 | return res 170 | else: 171 | print('Card Creation Failed.\n', res) 172 | return res 173 | 174 | 175 | async def create_collection(self, collection_name, parent_collection_id=None, parent_collection_name=None, return_results=False): 176 | """ 177 | Async version of create_collection. 178 | Create an empty collection, in the given location, utilizing the endpoint 'POST /api/collection/'. 179 | """ 180 | # Making sure we have the data we need 181 | if not parent_collection_id: 182 | if not parent_collection_name: 183 | print('Either the name of id of the parent collection must be provided.') 184 | if parent_collection_name == 'Root': 185 | parent_collection_id = None 186 | else: 187 | parent_collection_id = await self.get_item_id('collection', parent_collection_name) 188 | 189 | res = await self.post('/api/collection', json={'name': collection_name, 'parent_id': parent_collection_id, 'color': '#509EE3'}) 190 | if return_results: 191 | return res 192 | 193 | 194 | async def create_segment(self, segment_name, column_name, column_values, segment_description='', 195 | db_name=None, db_id=None, table_name=None, table_id=None, return_segment=False): 196 | """ 197 | Async version of create_segment. 198 | Create a segment using the given arguments utilizing the endpoint 'POST /api/segment/'. 199 | """ 200 | # Making sure we have the data needed 201 | if not table_name and not table_id: 202 | raise ValueError('Either the name or id of the table must be provided.') 203 | if not table_id: 204 | table_id = await self.get_item_id('table', table_name, db_id=db_id, db_name=db_name) 205 | if not table_name: 206 | table_name = await self.get_item_name(item_type='table', item_id=table_id) 207 | db_id = await self.get_db_id_from_table_id(table_id) 208 | 209 | colmuns_name_id_mapping = await self.get_columns_name_id(table_name=table_name, db_id=db_id) 210 | column_id = colmuns_name_id_mapping[column_name] 211 | 212 | # Create a segment blueprint 213 | segment_blueprint = { 214 | 'name': segment_name, 215 | 'description': segment_description, 216 | 'table_id': table_id, 217 | 'definition': {'source-table': table_id, 'filter': ['=', ['field-id', column_id]]} 218 | } 219 | 220 | # Add filtering values 221 | segment_blueprint['definition']['filter'].extend(column_values) 222 | 223 | # Create the segment 224 | res = await self.post('/api/segment/', json=segment_blueprint) 225 | if return_segment: 226 | return res 227 | -------------------------------------------------------------------------------- /metabase_api/metabase_api.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import getpass 3 | 4 | class Metabase_API(): 5 | 6 | def __init__(self, domain, email=None, password=None, api_key=None, basic_auth=False, is_admin=True): 7 | assert email is not None or api_key is not None 8 | self.domain = domain.rstrip('/') 9 | self.email = email 10 | self.auth = None 11 | if email: 12 | self.password = getpass.getpass(prompt='Please enter your password: ') if password is None else password 13 | self.session_id = None 14 | self.header = None 15 | if basic_auth: 16 | self.auth = (self.email, self.password) 17 | self.authenticate() 18 | else: 19 | self.header = {"X-API-KEY": api_key} 20 | # make sure the provided api key is correct 21 | res = requests.get(self.domain + '/api/database/1', headers=self.header) 22 | if res.status_code == 401: # unauthenticated 23 | raise ValueError('The provided API key is not correct.') 24 | 25 | self.is_admin = is_admin 26 | if not self.is_admin: 27 | print(''' 28 | Ask your Metabase admin to disable "Friendly Table and Field Names" (in Admin Panel > Settings > General). 29 | Without this some of the functions of the current package may not work as expected. 30 | ''') 31 | 32 | 33 | def authenticate(self): 34 | """Get a Session ID""" 35 | conn_header = { 36 | 'username':self.email, 37 | 'password':self.password 38 | } 39 | 40 | res = requests.post(self.domain + '/api/session', json=conn_header, auth=self.auth) 41 | if not res.ok: 42 | raise Exception(res) 43 | 44 | self.session_id = res.json()['id'] 45 | self.header = {'X-Metabase-Session':self.session_id} 46 | 47 | 48 | def validate_session(self): 49 | """Get a new session ID if the previous one has expired""" 50 | if not self.email: # if email was not provided then the authentication would be based on api key so there would be no session to validate 51 | return 52 | res = requests.get(self.domain + '/api/user/current', headers=self.header, auth=self.auth) 53 | 54 | if res.ok: # 200 55 | return True 56 | elif res.status_code == 401: # unauthorized 57 | return self.authenticate() 58 | else: 59 | raise Exception(res) 60 | 61 | 62 | 63 | # import REST Methods 64 | from ._rest_methods import get, post, put, delete 65 | # import helper functions 66 | from ._helper_methods import get_item_info, get_item_id, get_item_name, \ 67 | get_db_id_from_table_id, get_db_info, get_table_metadata, \ 68 | get_columns_name_id, friendly_names_is_disabled, verbose_print 69 | 70 | 71 | ################################################################## 72 | ###################### Custom Functions ########################## 73 | ################################################################## 74 | from .create_methods import create_card, create_collection, create_segment 75 | from .copy_methods import copy_card, copy_collection, copy_dashboard, copy_pulse 76 | 77 | def search(self, q, item_type=None): 78 | """ 79 | Search for Metabase objects and return their basic info. 80 | We can limit the search to a certain item type by providing a value for item_type keyword. 81 | 82 | Parameters 83 | ---------- 84 | q : search input 85 | item_type : to limit the search to certain item types (default:None, means no limit) 86 | """ 87 | assert item_type in [None, 'card', 'dashboard', 'collection', 'table', 'pulse', 'segment', 'metric' ] 88 | 89 | res = self.get(endpoint='/api/search/', params={'q':q}) 90 | if type(res) == dict: # in Metabase version *.40.0 the format of the returned result for this endpoint changed 91 | res = res['data'] 92 | if item_type is not None: 93 | res = [ item for item in res if item['model'] == item_type ] 94 | 95 | return res 96 | 97 | 98 | 99 | def get_card_data(self, card_name=None, card_id=None, collection_name=None, collection_id=None, 100 | data_format='json', parameters=None, format_rows=False): 101 | ''' 102 | Run the query associated with a card and get the results. 103 | 104 | Parameters 105 | ---------- 106 | data_format : specifies the format of the returned data: 107 | - 'json': every row is a dictionary of key-value pairs 108 | - 'csv': the entire result is returned as a string, where rows are separated by newlines and cells with commas. 109 | parameters : can be used to pass filter values: 110 | The format is like [{"type":"category","value":["val1","val2"],"target":["dimension",["template-tag","filter_variable_name"]]}] 111 | See the network tab when exporting the results using the web interface to get the proper format pattern. 112 | format_rows : whether the returned results should be formatted or not 113 | ''' 114 | assert data_format in [ 'json', 'csv' ] 115 | if parameters: 116 | assert type(parameters) == list 117 | 118 | if card_id is None: 119 | if card_name is None: 120 | raise ValueError('Either card_id or card_name must be provided.') 121 | card_id = self.get_item_id(item_name=card_name, 122 | collection_name=collection_name, 123 | collection_id=collection_id, 124 | item_type='card') 125 | 126 | import json 127 | params_json = { 128 | 'parameters':json.dumps(parameters), 129 | 'format_rows':'true' if format_rows else 'false' 130 | } 131 | 132 | # get the results 133 | res = self.post("/api/card/{}/query/{}".format(card_id, data_format), 'raw', data=params_json) 134 | 135 | # return the results in the requested format 136 | if data_format == 'json': 137 | return json.loads(res.text) 138 | if data_format == 'csv': 139 | return res.text.replace('null', '') 140 | 141 | 142 | 143 | def clone_card(self, card_id, 144 | source_table_id=None, target_table_id=None, 145 | source_table_name=None, target_table_name=None, 146 | new_card_name=None, new_card_collection_id=None, 147 | ignore_these_filters=None, return_card=False): 148 | """ 149 | *** work in progress *** 150 | Create a new card where the source of the old card is changed from 'source_table_id' to 'target_table_id'. 151 | The filters that were based on the old table would become based on the new table. 152 | In the current version of the function there are some limitations which would be removed in future versions: 153 | - The column names used in filters need to be the same in the source and target table (except the ones that are ignored by 'ignore_these_filters' param). 154 | - The source and target tables need to be in the same DB. 155 | 156 | Parameters 157 | ---------- 158 | card_id : id of the card 159 | source_table_id : The table that the filters of the card are based on 160 | target_table_id : The table that the filters of the cloned card would be based on 161 | new_card_name : Name of the cloned card. If not provided, the name of the source card is used. 162 | new_card_collection_id : The id of the collection that the cloned card should be saved in 163 | ignore_these_filters : A list of variable names of filters. The source of these filters would not change in the cloning process. 164 | return_card : Whether to return the info of the created card (default False) 165 | """ 166 | # Make sure we have the data we need 167 | if not source_table_id: 168 | if not source_table_name: 169 | raise ValueError('Either the name or id of the source table needs to be provided.') 170 | else: 171 | source_table_id = self.get_item_id('table', source_table_name) 172 | 173 | if not target_table_id: 174 | if not target_table_name: 175 | raise ValueError('Either the name or id of the target table needs to be provided.') 176 | else: 177 | target_table_id = self.get_item_id('table', target_table_name) 178 | 179 | if ignore_these_filters: 180 | assert type(ignore_these_filters) == list 181 | 182 | # get the card info 183 | card_info = self.get_item_info('card', card_id) 184 | # get the mappings, both name -> id and id -> name 185 | target_table_col_name_id_mapping = self.get_columns_name_id(table_id=target_table_id) 186 | source_table_col_id_name_mapping = self.get_columns_name_id(table_id=source_table_id, column_id_name=True) 187 | 188 | # native questions 189 | if card_info['dataset_query']['type'] == 'native': 190 | filters_data = card_info['dataset_query']['native']['template-tags'] 191 | # change the underlying table for the card 192 | if not source_table_name: 193 | source_table_name = self.get_item_name('table', source_table_id) 194 | if not target_table_name: 195 | target_table_name = self.get_item_name('table', target_table_id) 196 | card_info['dataset_query']['native']['query'] = card_info['dataset_query']['native']['query'].replace(source_table_name, target_table_name) 197 | # change filters source 198 | for filter_variable_name, data in filters_data.items(): 199 | if ignore_these_filters is not None and filter_variable_name in ignore_these_filters: 200 | continue 201 | column_id = data['dimension'][1] 202 | column_name = source_table_col_id_name_mapping[column_id] 203 | target_col_id = target_table_col_name_id_mapping[column_name] 204 | card_info['dataset_query']['native']['template-tags'][filter_variable_name]['dimension'][1] = target_col_id 205 | 206 | # simple/custom questions 207 | elif card_info['dataset_query']['type'] == 'query': 208 | 209 | query_data = card_info['dataset_query']['query'] 210 | 211 | # change the underlying table for the card 212 | query_data['source-table'] = target_table_id 213 | 214 | # transform to string so it is easier to replace the column IDs 215 | query_data_str = str(query_data) 216 | 217 | # find column IDs 218 | import re 219 | res = re.findall(r"\['field', .*?\]", query_data_str) 220 | source_column_IDs = [ eval(i)[1] for i in res ] 221 | 222 | # replace column IDs from old table with the column IDs from new table 223 | for source_col_id in source_column_IDs: 224 | source_col_name = source_table_col_id_name_mapping[source_col_id] 225 | target_col_id = target_table_col_name_id_mapping[source_col_name] 226 | query_data_str = query_data_str.replace("['field', {}, ".format(source_col_id), "['field', {}, ".format(target_col_id)) 227 | 228 | card_info['dataset_query']['query'] = eval(query_data_str) 229 | 230 | new_card_json = {} 231 | for key in ['dataset_query', 'display', 'visualization_settings']: 232 | new_card_json[key] = card_info[key] 233 | 234 | if new_card_name: 235 | new_card_json['name'] = new_card_name 236 | else: 237 | new_card_json['name'] = card_info['name'] 238 | 239 | if new_card_collection_id: 240 | new_card_json['collection_id'] = new_card_collection_id 241 | else: 242 | new_card_json['collection_id'] = card_info['collection_id'] 243 | 244 | if return_card: 245 | return self.create_card(custom_json=new_card_json, verbose=True, return_card=return_card) 246 | else: 247 | self.create_card(custom_json=new_card_json, verbose=True) 248 | 249 | 250 | 251 | def move_to_archive(self, item_type, item_name=None, item_id=None, 252 | collection_name=None, collection_id=None, table_id=None, verbose=False): 253 | '''Archive the given item. For deleting the item use the 'delete_item' function.''' 254 | assert item_type in ['card', 'dashboard', 'collection', 'pulse', 'segment'] 255 | 256 | if not item_id: 257 | if not item_name: 258 | raise ValueError('Either the name or id of the {} must be provided.'.format(item_type)) 259 | if item_type == 'collection': 260 | item_id = self.get_item_id('collection', item_name) 261 | elif item_type == 'segment': 262 | item_id = self.get_item_id('segment', item_name, table_id=table_id) 263 | else: 264 | item_id = self.get_item_id(item_type, item_name, collection_id, collection_name) 265 | 266 | if item_type == 'segment': 267 | # 'revision_message' is mandatory for archiving segments 268 | res = self.put('/api/{}/{}'.format(item_type, item_id), json={'archived':True, 'revision_message':'archived!'}) 269 | else: 270 | res = self.put('/api/{}/{}'.format(item_type, item_id), json={'archived':True}) 271 | 272 | if res in [200, 202]: # for segments the success status code returned is 200 for others it is 202 273 | self.verbose_print(verbose, 'Successfully Archived.') 274 | else: 275 | print('Archiving Failed.') 276 | 277 | return res 278 | 279 | 280 | 281 | def delete_item(self, item_type, item_name=None, item_id=None, 282 | collection_name=None, collection_id=None, verbose=False): 283 | ''' 284 | Delete the given item. Use carefully (this is different from archiving). 285 | Currently Collections and Segments cannot be deleted using the Metabase API. 286 | ''' 287 | assert item_type in ['card', 'dashboard', 'pulse'] 288 | if not item_id: 289 | if not item_name: 290 | raise ValueError('Either the name or id of the {} must be provided.'.format(item_type)) 291 | item_id = self.get_item_id(item_type, item_name, collection_id, collection_name) 292 | 293 | return self.delete('/api/{}/{}'.format(item_type, item_id)) 294 | 295 | 296 | 297 | def update_column(self, params, column_id=None, column_name=None, 298 | table_id=None, table_name=None, db_id=None, db_name=None): 299 | ''' 300 | Update the column in data model by providing values for 'params'. 301 | E.g. for changing the column type to 'Category' in data model, use: params={'semantic_type':'type/Category'} 302 | (For Metabase versions before v.39, use: params={'special_type':'type/Category'}). 303 | Other parameter values: https://www.metabase.com/docs/latest/api-documentation.html#put-apifieldid 304 | ''' 305 | assert type(params) == dict 306 | 307 | # making sure we have the data we need 308 | if not column_id: 309 | if not column_name: 310 | raise ValueError('Either the name or id of the column needs to be provided.') 311 | 312 | if not table_id: 313 | if not table_name: 314 | raise ValueError('When column_id is not given, either the name or id of the table needs to be provided.') 315 | table_id = self.get_item_id('table', table_name, db_id=db_id, db_name=db_name) 316 | 317 | columns_name_id_mapping = self.get_columns_name_id(table_name=table_name, table_id=table_id, db_name=db_name, db_id=db_id) 318 | column_id = columns_name_id_mapping.get(column_name) 319 | if column_id is None: 320 | raise ValueError('There is no column named {} in the provided table'.format(column_name)) 321 | 322 | res_status_code = self.put('/api/field/{}'.format(column_id), json=params) 323 | if res_status_code != 200: 324 | print('Column Update Failed.') 325 | 326 | return res_status_code 327 | 328 | 329 | 330 | def add_card_to_dashboard(self, card_id, dashboard_id): 331 | params = { 332 | 'cardId': card_id 333 | } 334 | self.post(f'/api/dashboard/{dashboard_id}/cards', json=params) 335 | 336 | 337 | 338 | @staticmethod 339 | def make_json(raw_json, prettyprint=False): 340 | """Turn the string copied from the Inspect->Network window into a Dict.""" 341 | import json 342 | ret_dict = json.loads(raw_json) 343 | if prettyprint: 344 | import pprint 345 | pprint.pprint(ret_dict) 346 | 347 | return ret_dict 348 | -------------------------------------------------------------------------------- /metabase_api/metabase_api_async.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import getpass 3 | 4 | class Metabase_API_Async: 5 | """ 6 | Async version of the Metabase API wrapper. 7 | Provides asynchronous methods to interact with the Metabase API. 8 | """ 9 | 10 | def __init__(self, domain, email=None, password=None, api_key=None, basic_auth=False, is_admin=True, timeout=None): 11 | assert email is not None or api_key is not None 12 | self.domain = domain.rstrip('/') 13 | self.email = email 14 | self.auth = None 15 | self.password = None 16 | self.session_id = None 17 | self.header = None 18 | self.is_admin = is_admin 19 | self.timeout = timeout 20 | 21 | if email: 22 | self.password = getpass.getpass(prompt='Please enter your password: ') if password is None else password 23 | if basic_auth: 24 | self.auth = True # We'll use aiohttp.BasicAuth in the request methods 25 | else: 26 | self.auth = None 27 | else: 28 | self.header = {"X-API-KEY": api_key} 29 | 30 | if not self.is_admin: 31 | print(''' 32 | Ask your Metabase admin to disable "Friendly Table and Field Names" (in Admin Panel > Settings > General). 33 | Without this some of the functions of the current package may not work as expected. 34 | ''') 35 | 36 | async def authenticate_async(self): 37 | """Asynchronously get a Session ID""" 38 | conn_header = { 39 | 'username': self.email, 40 | 'password': self.password 41 | } 42 | 43 | auth = (self.email, self.password) if self.auth else None 44 | async with httpx.AsyncClient() as client: 45 | res = await client.post( 46 | self.domain + '/api/session', 47 | json=conn_header, 48 | auth=auth 49 | ) 50 | if res.status_code != 200: 51 | raise Exception(f"Authentication failed with status {res.status_code}") 52 | 53 | data = res.json() 54 | self.session_id = data['id'] 55 | self.header = {'X-Metabase-Session': self.session_id} 56 | 57 | async def validate_session_async(self): 58 | """Asynchronously get a new session ID if the previous one has expired""" 59 | if not self.email: # Using API key 60 | return 61 | 62 | if not self.session_id: # First request 63 | return await self.authenticate_async() 64 | 65 | auth = (self.email, self.password) if self.auth else None 66 | async with httpx.AsyncClient() as client: 67 | res = await client.get( 68 | self.domain + '/api/user/current', 69 | headers=self.header, 70 | auth=auth 71 | ) 72 | if res.status_code == 200: 73 | return True 74 | elif res.status_code == 401: # unauthorized 75 | return await self.authenticate_async() 76 | else: 77 | raise Exception(f"Session validation failed with status {res.status_code}") 78 | 79 | 80 | 81 | # Import async REST methods 82 | from ._rest_methods_async import get, post, put, delete 83 | # import helper functions 84 | from ._helper_methods_async import get_item_info, get_item_id, get_item_name, \ 85 | get_db_id_from_table_id, get_db_info, get_table_metadata, \ 86 | get_columns_name_id, friendly_names_is_disabled, verbose_print 87 | 88 | 89 | ################################################################## 90 | ###################### Custom Functions ########################## 91 | ################################################################## 92 | from .create_methods_async import create_card, create_collection, create_segment 93 | from .copy_methods_async import copy_card, copy_collection, copy_dashboard, copy_pulse 94 | 95 | async def search(self, q, item_type=None): 96 | """ 97 | Async version of search function. 98 | Search for Metabase objects and return their basic info. 99 | We can limit the search to a certain item type by providing a value for item_type keyword. 100 | 101 | Parameters 102 | ---------- 103 | q : search input 104 | item_type : to limit the search to certain item types (default:None, means no limit) 105 | """ 106 | assert item_type in [None, 'card', 'dashboard', 'collection', 'table', 'pulse', 'segment', 'metric'] 107 | 108 | res = await self.get(endpoint='/api/search/', params={'q': q}) 109 | if type(res) == dict: # in Metabase version *.40.0 the format of the returned result for this endpoint changed 110 | res = res['data'] 111 | if item_type is not None: 112 | res = [item for item in res if item['model'] == item_type] 113 | 114 | return res 115 | 116 | 117 | 118 | async def get_card_data(self, card_name=None, card_id=None, collection_name=None, collection_id=None, 119 | data_format='json', parameters=None, format_rows=False): 120 | ''' 121 | Async version of get_card_data. 122 | Run the query associated with a card and get the results. 123 | 124 | Parameters 125 | ---------- 126 | data_format : specifies the format of the returned data: 127 | - 'json': every row is a dictionary of key-value pairs 128 | - 'csv': the entire result is returned as a string, where rows are separated by newlines and cells with commas. 129 | parameters : can be used to pass filter values: 130 | The format is like [{"type":"category","value":["val1","val2"],"target":["dimension",["template-tag","filter_variable_name"]]}] 131 | See the network tab when exporting the results using the web interface to get the proper format pattern. 132 | format_rows : whether the returned results should be formatted or not 133 | ''' 134 | assert data_format in ['json', 'csv'] 135 | if parameters: 136 | assert type(parameters) == list 137 | 138 | if card_id is None: 139 | if card_name is None: 140 | raise ValueError('Either card_id or card_name must be provided.') 141 | card_id = await self.get_item_id(item_name=card_name, 142 | collection_name=collection_name, 143 | collection_id=collection_id, 144 | item_type='card') 145 | 146 | import json 147 | params_json = { 148 | 'parameters': json.dumps(parameters), 149 | 'format_rows': 'true' if format_rows else 'false' 150 | } 151 | 152 | # get the results 153 | res = await self.post(f"/api/card/{card_id}/query/{data_format}", 'raw', data=params_json) 154 | 155 | # return the results in the requested format 156 | if data_format == 'json': 157 | text = res.text if hasattr(res, 'text') else await res.text() 158 | return json.loads(text) 159 | if data_format == 'csv': 160 | text = res.text if hasattr(res, 'text') else await res.text() 161 | return text.replace('null', '') 162 | 163 | 164 | 165 | async def clone_card(self, card_id, 166 | source_table_id=None, target_table_id=None, 167 | source_table_name=None, target_table_name=None, 168 | new_card_name=None, new_card_collection_id=None, 169 | ignore_these_filters=None, return_card=False): 170 | """ 171 | Async version of clone_card. 172 | """ 173 | if not source_table_id: 174 | if not source_table_name: 175 | raise ValueError('Either the name or id of the source table needs to be provided.') 176 | else: 177 | source_table_id = await self.get_item_id('table', source_table_name) 178 | 179 | if not target_table_id: 180 | if not target_table_name: 181 | raise ValueError('Either the name or id of the target table needs to be provided.') 182 | else: 183 | target_table_id = await self.get_item_id('table', target_table_name) 184 | 185 | if ignore_these_filters: 186 | assert type(ignore_these_filters) == list 187 | 188 | card_info = await self.get_item_info('card', card_id) 189 | target_table_col_name_id_mapping = await self.get_columns_name_id(table_id=target_table_id) 190 | source_table_col_id_name_mapping = await self.get_columns_name_id(table_id=source_table_id, column_id_name=True) 191 | 192 | if card_info['dataset_query']['type'] == 'native': 193 | filters_data = card_info['dataset_query']['native']['template-tags'] 194 | if not source_table_name: 195 | source_table_name = await self.get_item_name('table', source_table_id) 196 | if not target_table_name: 197 | target_table_name = await self.get_item_name('table', target_table_id) 198 | card_info['dataset_query']['native']['query'] = card_info['dataset_query']['native']['query'].replace(source_table_name, target_table_name) 199 | for filter_variable_name, data in filters_data.items(): 200 | if ignore_these_filters is not None and filter_variable_name in ignore_these_filters: 201 | continue 202 | column_id = data['dimension'][1] 203 | column_name = source_table_col_id_name_mapping[column_id] 204 | target_col_id = target_table_col_name_id_mapping[column_name] 205 | card_info['dataset_query']['native']['template-tags'][filter_variable_name]['dimension'][1] = target_col_id 206 | 207 | elif card_info['dataset_query']['type'] == 'query': 208 | query_data = card_info['dataset_query']['query'] 209 | query_data['source-table'] = target_table_id 210 | query_data_str = str(query_data) 211 | import re 212 | res = re.findall(r"\['field', .*?\]", query_data_str) 213 | source_column_IDs = [ eval(i)[1] for i in res ] 214 | for source_col_id in source_column_IDs: 215 | source_col_name = source_table_col_id_name_mapping[source_col_id] 216 | target_col_id = target_table_col_name_id_mapping[source_col_name] 217 | query_data_str = query_data_str.replace("['field', {}, ".format(source_col_id), "['field', {}, ".format(target_col_id)) 218 | card_info['dataset_query']['query'] = eval(query_data_str) 219 | 220 | new_card_json = {} 221 | for key in ['dataset_query', 'display', 'visualization_settings']: 222 | new_card_json[key] = card_info[key] 223 | 224 | if new_card_name: 225 | new_card_json['name'] = new_card_name 226 | else: 227 | new_card_json['name'] = card_info['name'] 228 | 229 | if new_card_collection_id: 230 | new_card_json['collection_id'] = new_card_collection_id 231 | else: 232 | new_card_json['collection_id'] = card_info['collection_id'] 233 | 234 | if return_card: 235 | return await self.create_card(custom_json=new_card_json, verbose=True, return_card=return_card) 236 | else: 237 | await self.create_card(custom_json=new_card_json, verbose=True) 238 | 239 | 240 | 241 | async def move_to_archive(self, item_type, item_name=None, item_id=None, 242 | collection_name=None, collection_id=None, table_id=None, verbose=False): 243 | ''' 244 | Async version of move_to_archive. 245 | ''' 246 | assert item_type in ['card', 'dashboard', 'collection', 'pulse', 'segment'] 247 | 248 | if not item_id: 249 | if not item_name: 250 | raise ValueError('Either the name or id of the {} must be provided.'.format(item_type)) 251 | if item_type == 'collection': 252 | item_id = await self.get_item_id('collection', item_name) 253 | elif item_type == 'segment': 254 | item_id = await self.get_item_id('segment', item_name, table_id=table_id) 255 | else: 256 | item_id = await self.get_item_id(item_type, item_name, collection_id, collection_name) 257 | 258 | if item_type == 'segment': 259 | res = await self.put('/api/{}/{}'.format(item_type, item_id), json={'archived':True, 'revision_message':'archived!'}) 260 | else: 261 | res = await self.put('/api/{}/{}'.format(item_type, item_id), json={'archived':True}) 262 | 263 | if res in [200, 202]: 264 | await self.verbose_print(verbose, 'Successfully Archived.') 265 | else: 266 | print('Archiving Failed.') 267 | 268 | return res 269 | 270 | 271 | 272 | async def delete_item(self, item_type, item_name=None, item_id=None, 273 | collection_name=None, collection_id=None, verbose=False): 274 | ''' 275 | Async version of delete_item. 276 | ''' 277 | assert item_type in ['card', 'dashboard', 'pulse'] 278 | if not item_id: 279 | if not item_name: 280 | raise ValueError('Either the name or id of the {} must be provided.'.format(item_type)) 281 | item_id = await self.get_item_id(item_type, item_name, collection_id, collection_name) 282 | 283 | return await self.delete('/api/{}/{}'.format(item_type, item_id)) 284 | 285 | 286 | 287 | async def update_column(self, params, column_id=None, column_name=None, 288 | table_id=None, table_name=None, db_id=None, db_name=None): 289 | ''' 290 | Async version of update_column. 291 | ''' 292 | assert type(params) == dict 293 | 294 | if not column_id: 295 | if not column_name: 296 | raise ValueError('Either the name or id of the column needs to be provided.') 297 | 298 | if not table_id: 299 | if not table_name: 300 | raise ValueError('When column_id is not given, either the name or id of the table needs to be provided.') 301 | table_id = await self.get_item_id('table', table_name, db_id=db_id, db_name=db_name) 302 | 303 | columns_name_id_mapping = await self.get_columns_name_id(table_name=table_name, table_id=table_id, db_name=db_name, db_id=db_id) 304 | column_id = columns_name_id_mapping.get(column_name) 305 | if column_id is None: 306 | raise ValueError('There is no column named {} in the provided table'.format(column_name)) 307 | 308 | res_status_code = await self.put('/api/field/{}'.format(column_id), json=params) 309 | if res_status_code != 200: 310 | print('Column Update Failed.') 311 | 312 | return res_status_code 313 | 314 | 315 | 316 | async def add_card_to_dashboard(self, card_id, dashboard_id): 317 | params = { 318 | 'cardId': card_id 319 | } 320 | await self.post(f'/api/dashboard/{dashboard_id}/cards', json=params) 321 | 322 | @staticmethod 323 | async def make_json(raw_json, prettyprint=False): 324 | """Async version of make_json.""" 325 | import json 326 | ret_dict = json.loads(raw_json) 327 | if prettyprint: 328 | import pprint 329 | pprint.pprint(ret_dict) 330 | return ret_dict 331 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2.25.0 2 | httpx>=0.23.0 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="metabase-api", 8 | version="3.5.1", 9 | author="Vahid Vaezian", 10 | author_email="vahid.vaezian@gmail.com", 11 | description="A Python Wrapper for Metabase API", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/vvaezian/metabase_api_python", 15 | packages=setuptools.find_packages(), 16 | install_requires=[ 17 | "requests", 18 | "httpx", 19 | ], 20 | classifiers=[ 21 | "Programming Language :: Python :: 3", 22 | "License :: OSI Approved :: MIT License", 23 | "Operating System :: OS Independent", 24 | ], 25 | ) 26 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/data/metabase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vvaezian/metabase_api_python/0ca8835b60b8ee0f3d5c3ee43b72c302fee3facd/tests/data/metabase.png -------------------------------------------------------------------------------- /tests/data/test_db.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vvaezian/metabase_api_python/0ca8835b60b8ee0f3d5c3ee43b72c302fee3facd/tests/data/test_db.sqlite -------------------------------------------------------------------------------- /tests/initial_setup.sh: -------------------------------------------------------------------------------- 1 | # This file does some initial setup so we can run tests on a local Metabase: 2 | # - Downloads the Metabase jar file 3 | # - Runs it and waits for initialization to complete 4 | # - Creates an admin user (email: abc.xyz@gmail.com, password: xzy12345) and does the initial setup of Metabase 5 | # - Creates some collections/cards/dashboards which will be used when running the tests 6 | 7 | while getopts 'v:' flag; do 8 | case "${flag}" in 9 | v) MB_VERSION="${OPTARG}" ;; 10 | *) echo 'Accepted flags: -v' 11 | exit 1 ;; 12 | esac 13 | done 14 | 15 | if [[ $MB_VERSION = '' ]] 16 | then 17 | echo 'Please provide the Metabase version using -v flag.' 18 | exit 1 19 | fi 20 | 21 | # cleaup (in case the script is not running for the first time) 22 | rm -f metabase.db.mv.db 23 | rm -f metabase.db.trace.db 24 | 25 | # downloading metabase jar file 26 | wget -O metabase.jar -q https://downloads.metabase.com/v$MB_VERSION/metabase.jar 27 | 28 | # starting metabase jar locally 29 | java -jar metabase.jar > logs 2>&1 & 30 | 31 | # waiting 120 seconds for the initialization to complete 32 | sleep 120 33 | 34 | # checking whether the metabase initialization has completed. If not, wait another 45 seconds 35 | success='False' 36 | grep -q "Metabase Initialization COMPLETE" logs 37 | if [[ $? -eq 0 ]] 38 | then 39 | echo 'success!' 40 | success='True' 41 | else 42 | echo "Waiting an extra 45 seconds for the initialization to complete" 43 | sleep 45 44 | grep -q "Metabase Initialization COMPLETE" logs 45 | if [[ $? -eq 0 ]] 46 | then 47 | echo 'success!' 48 | success='True' 49 | else 50 | echo 'failure!' 51 | fi 52 | fi 53 | 54 | if [[ $success = 'False' ]] 55 | then 56 | exit 1 57 | fi 58 | 59 | 60 | # getting the seup token 61 | setup_token=$(curl -X GET http://localhost:3000/api/session/properties | perl -pe 's/.*"setup-token":"(.*?)".*/\1/') 62 | # initial setup and getting the session_id 63 | session_id=$(curl -X POST -H "Content-Type: application/json" -d '{ "token": "'$setup_token'", "user": {"first_name": "abc", "last_name": "xyz", "email": "abc.xyz@gmail.com", "password": "xzy12345"},"prefs": {"allow_tracking": true, "site_name": "test_site"}}' http://localhost:3000/api/setup | perl -pe 's/^.......(.*)..$/\1/') 64 | echo $session_id 65 | 66 | # copying the SQLite test db to metabase plugins directory (for editing the SQLite db you can use: https://sqliteviewer.flowsoft7.com/) 67 | cp tests/data/test_db.sqlite plugins 68 | # adding test_db to Metabase as a new database 69 | curl -X POST -H "Content-Type: application/json" -H "X-Metabase-Session:$session_id" -d '{"engine":"sqlite","name":"test_db","details":{"db":"plugins/test_db.sqlite","advanced-options":false},"is_full_sync":true}' http://localhost:3000/api/database # id of the created db connection is 2 because 1 is used for sample database 70 | 71 | # creaing base collections which will be used during the test 72 | curl -X POST -H "Content-Type: application/json" -H "X-Metabase-Session:$session_id" -d '{"name":"test_collection", "color":"#509EE3"}' http://localhost:3000/api/collection # id of the created collection is 2 because id 1 is reserved for the personal collection of admin 73 | curl -X POST -H "Content-Type: application/json" -H "X-Metabase-Session:$session_id" -d '{"name":"test_collection_dup", "parent_id":2, "color":"#509EE3"}' http://localhost:3000/api/collection # collection_id: 3 74 | curl -X POST -H "Content-Type: application/json" -H "X-Metabase-Session:$session_id" -d '{"name":"test_collection_dup", "parent_id":2, "color":"#509EE3"}' http://localhost:3000/api/collection # collection_id: 4 75 | 76 | # creating base cards which will be used during the test 77 | json='{ 78 | "name": "test_card", 79 | "display": "table", 80 | "dataset_query": { 81 | "database": 2, 82 | "query": { "source-table": 9 }, 83 | "type": "query" 84 | }, 85 | "visualization_settings": {}, 86 | "collection_id": 2 87 | }' 88 | echo "$json" | curl -X POST http://localhost:3000/api/card -H "Content-Type: application/json" -H "X-Metabase-Session:$session_id" -d @- > output 89 | 90 | # the order of the IDs assigned to columns is not based on the db column order 91 | grep -q ',73.*,72' output 92 | if [[ $? -eq 0 ]]; then 93 | col1_id=73; 94 | col2_id=72; 95 | else 96 | col1_id=72; 97 | col2_id=73; 98 | fi 99 | 100 | json='{ 101 | "name":"test_card_2", 102 | "dataset_query":{ 103 | "type":"native", 104 | "native":{ 105 | "query":"select *\nfrom test_table2\nwhere 1 = 1 \n[[ and {{test_filter}} ]]\n", 106 | "template-tags":{ 107 | "test_filter":{ 108 | "name":"test_filter", 109 | "display-name":"Test filter", 110 | "type":"dimension", 111 | "dimension":["field",COL1_ID,null], 112 | "widget-type":"string/=", 113 | "default":null, 114 | "id":"810912da-ead5-c87e-de32-6dc5723b9067" 115 | } 116 | } 117 | } 118 | ,"database":2 119 | }, 120 | "display":"table", 121 | "visualization_settings":{}, 122 | "parameters":[{ 123 | "type":"string/=", 124 | "target":["dimension",["template-tag","test_filter"]], 125 | "name":"Test filter", 126 | "slug":"test_filter", 127 | "default":null, 128 | "id":"810912da-ead5-c87e-de32-6dc5723b9067" 129 | }], 130 | "collection_id":2 131 | }' 132 | # add the value of $col1_id (because of presense of single and double quotes in the json string, I decided to add the variable value in this way) 133 | json=$(echo "$json" | sed "s/COL1_ID/$col1_id/g") 134 | 135 | echo "$json" | curl -X POST http://localhost:3000/api/card -H "Content-Type: application/json" -H "X-Metabase-Session:$session_id" -d @- 136 | 137 | json='{ 138 | "name":"test_card_3", 139 | "dataset_query":{ 140 | "type":"query", 141 | "query":{ 142 | "source-table":9, 143 | "filter":["=",["field",COL1_ID,null],"row1 cell1","row3 cell1"]}, 144 | "database":2 145 | }, 146 | "display":"table", 147 | "visualization_settings":{}, 148 | "collection_id":2 149 | }' 150 | # add the value of $col1_id 151 | json=$(echo "$json" | sed "s/COL1_ID/$col1_id/g") 152 | 153 | echo "$json" | curl -X POST http://localhost:3000/api/card -H "Content-Type: application/json" -H "X-Metabase-Session:$session_id" -d @- 154 | 155 | json='{ 156 | "name":"test_card_4", 157 | "dataset":false, 158 | "dataset_query":{ 159 | "database":2, 160 | "query":{ 161 | "source-table":9, 162 | "aggregation":[["avg",["field",COL2_ID,null]]], 163 | "breakout":[["field",COL1_ID,null]], 164 | "order-by":[["desc",["aggregation",0,null]]] 165 | }, 166 | "type":"query" 167 | }, 168 | "display":"bar", 169 | "visualization_settings":{"table.pivot":false,"graph.dimensions":["col1"],"graph.metrics":["avg"]}, 170 | "collection_id":2 171 | }' 172 | # add the value of $col1_id and $col2_id 173 | json=$(echo "$json" | sed "s/COL1_ID/$col1_id/g" | sed "s/COL2_ID/$col2_id/g") 174 | 175 | echo "$json" | curl -X POST http://localhost:3000/api/card -H "Content-Type: application/json" -H "X-Metabase-Session:$session_id" -d @- 176 | 177 | # create a test dashboard 178 | curl -X POST http://localhost:3000/api/dashboard -H "Content-Type: application/json" -H "X-Metabase-Session:$session_id" -d '{"collection_id":2,"name":"test_dashboard"}' 179 | # add the test_card to the dashboard 180 | curl -X POST http://localhost:3000/api/dashboard/1/cards -H "Content-Type: application/json" -H "X-Metabase-Session:$session_id" -d '{"cardId":1}' 181 | json='{ 182 | "cards":[{ 183 | "card_id":1, 184 | "row":0, 185 | "col":0, 186 | "size_x":4, 187 | "size_y":5, 188 | "series":[], 189 | "visualization_settings":{}, 190 | "parameter_mappings":[] 191 | }] 192 | }' 193 | echo "$json" | curl -X PUT http://localhost:3000/api/dashboard/1/cards -H "Content-Type: application/json" -H "X-Metabase-Session:$session_id" -d @- 194 | 195 | # diable friendly table and field names 196 | curl -X PUT http://localhost:3000/api/setting/humanization-strategy -H "Content-Type: application/json" -H "X-Metabase-Session:$session_id" -d '{"value":"none"}' 197 | -------------------------------------------------------------------------------- /tests/readme.md: -------------------------------------------------------------------------------- 1 | To run tests locally: 2 | - Clone the repo: `git clone https://github.com/vvaezian/metabase_api_python.git` 3 | - Go to the repo directory: `cd metabase_api_python` 4 | - Run the initial setup for the desired Metabase version: `./tests/initial_setup.sh -v 0.49.9` 5 | This will download the Metabase jar file, runs it in the background, creates an admin user, creates some collections/cards/dashboards which will be used during unittest 6 | - Run the unittests: `python3 -m unittest tests/test_metabase_api.py` 7 | 8 | 9 | After `initial_setup.sh` is finished, you can login to the running local Metabase by browsing 'http://localhost:3000' and entering email: 'abc.xyz@gmail.com' and password 'xzy12345' 10 | 11 | ![metabase](data/metabase.png) -------------------------------------------------------------------------------- /tests/test_metabase_api.py: -------------------------------------------------------------------------------- 1 | from metabase_api.metabase_api import Metabase_API 2 | import datetime 3 | import unittest 4 | 5 | 6 | mb = Metabase_API('http://localhost:3000', 'abc.xyz@gmail.com', 'xzy12345') 7 | 8 | 9 | class Metabase_API_Test(unittest.TestCase): 10 | 11 | from collections import defaultdict 12 | cleanup_objects = defaultdict(list) 13 | 14 | 15 | def setUp(self): # runs before every test 16 | pass 17 | 18 | 19 | def tearDown(self): # runs after every test 20 | for item_type, item_id_list in Metabase_API_Test.cleanup_objects.items(): 21 | if item_type in ['card', 'dashboard', 'pulse']: 22 | for item_id in item_id_list: 23 | mb.delete_item(item_type=item_type, item_id=item_id) 24 | if item_type in ['collection', 'segment']: # these cannot be deleted, so we archive them 25 | for item_id in item_id_list: 26 | mb.move_to_archive(item_type=item_type, item_id=item_id) 27 | 28 | 29 | 30 | 31 | ### Testing the Helper Functions 32 | 33 | def test_get_item_info(self): 34 | # database 35 | res = mb.get_item_info('database', 2) 36 | self.assertEqual(res['name'], 'test_db') 37 | self.assertEqual(res['id'], 2) 38 | 39 | # table 40 | res = mb.get_item_info('table', 9) 41 | self.assertEqual(res['name'], 'test_table2') 42 | self.assertEqual(res['id'], 9) 43 | 44 | # card 45 | res = mb.get_item_info('card', 1) 46 | self.assertEqual(res['name'], 'test_card') 47 | self.assertEqual(res['id'], 1) 48 | 49 | # collection 50 | res = mb.get_item_info('collection', 2) 51 | self.assertEqual(res['name'], 'test_collection') 52 | self.assertEqual(res['id'], 2) 53 | 54 | # dashboard 55 | res = mb.get_item_info('dashboard', 1) 56 | self.assertEqual(res['name'], 'test_dashboard') 57 | self.assertEqual(res['id'], 1) 58 | 59 | 60 | 61 | def test_get_item_name(self): 62 | # database 63 | db_name = mb.get_item_name('database', 2) 64 | self.assertEqual(db_name, 'test_db') 65 | 66 | # table 67 | table_name = mb.get_item_name('table', 9) 68 | self.assertEqual(table_name, 'test_table2') 69 | 70 | # card 71 | card_name = mb.get_item_name('card', 1) 72 | self.assertEqual(card_name, 'test_card') 73 | 74 | # collection 75 | collection_name = mb.get_item_name('collection', 2) 76 | self.assertEqual(collection_name, 'test_collection') 77 | 78 | # dashboard 79 | dashboard_name = mb.get_item_name('dashboard', 1) 80 | self.assertEqual(dashboard_name, 'test_dashboard') 81 | 82 | 83 | 84 | def test_get_item_id(self): 85 | # database 86 | db_id = mb.get_item_id('database', 'test_db') 87 | self.assertEqual(db_id, 2) 88 | 89 | # table 90 | table_id = mb.get_item_id('table', 'test_table') 91 | self.assertEqual(table_id, 10) 92 | 93 | # card 94 | card_id = mb.get_item_id('card', 'test_card') 95 | self.assertEqual(card_id, 1) 96 | 97 | # collection 98 | collection_id = mb.get_item_id('collection', 'test_collection') 99 | self.assertEqual(collection_id, 2) 100 | 101 | with self.assertRaises(ValueError) as error: 102 | mb.get_item_id('collection', 'test_collection_dup') 103 | self.assertEqual(str(error.exception), 'There is more than one collection with the name "test_collection_dup"') 104 | 105 | with self.assertRaises(ValueError) as error: 106 | mb.get_item_id('collection', 'xyz') 107 | self.assertEqual(str(error.exception), 'There is no collection with the name "xyz"') 108 | 109 | # dashboard 110 | dashboard_id = mb.get_item_id('dashboard', 'test_dashboard') 111 | self.assertEqual(dashboard_id, 1) 112 | 113 | 114 | 115 | def test_get_db_id_from_table_id(self): 116 | db_id = mb.get_db_id_from_table_id(9) 117 | self.assertEqual(db_id, 2) 118 | 119 | 120 | 121 | def test_get_table_metadata(self): 122 | table_info = mb.get_table_metadata(table_id=9) 123 | self.assertEqual(table_info['fields'][0]['name'], 'col1') 124 | 125 | 126 | 127 | def test_get_columns_name_id(self): 128 | name_id_mapping = mb.get_columns_name_id(table_id=8) # table with id 8 is the products table from sample dataset 129 | self.assertEqual(name_id_mapping['CATEGORY'], 64) 130 | 131 | id_name_mapping = mb.get_columns_name_id(table_id=8, column_id_name=True) 132 | self.assertEqual(id_name_mapping[64], 'CATEGORY') 133 | 134 | 135 | 136 | ### Testing the Custom Functions 137 | 138 | def test_create_card(self): 139 | t = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') 140 | res1 = mb.create_card(card_name=f'test_create_card_{t}', table_name='test_table', collection_id=2, return_card=True) 141 | 142 | t = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') 143 | card_info = { 144 | 'name': f'test_create_card_json1_{t}', 145 | 'display': 'table', 146 | 'dataset_query': { 147 | 'database': 2, 148 | 'native': { 'query': 'select * from test_table' }, 149 | 'type': 'native' 150 | }, 151 | 'collection_id':2 152 | } 153 | res2 = mb.create_card(custom_json=card_info, return_card=True) 154 | Metabase_API_Test.cleanup_objects['card'].append(res2['id']) 155 | 156 | t = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') 157 | card_info = { 158 | 'name': f'test_create_card_json2_{t}', 159 | 'display': 'table', 160 | 'dataset_query': { 161 | 'database': 2, 162 | 'native': { 'query': 'select * from test_table' }, 163 | 'type': 'native' 164 | } 165 | } 166 | res3 = mb.create_card(custom_json=card_info, collection_id=2, return_card=True) 167 | Metabase_API_Test.cleanup_objects['card'].append(res3['id']) 168 | 169 | t = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') 170 | card_info = { 171 | 'name': f'test_create_card_json3_{t}', 172 | #'display': 'table', 173 | 'dataset_query': { 174 | 'database': 2, 175 | 'native': { 'query': 'select * from test_table' }, 176 | 'type': 'native' 177 | } 178 | } 179 | with self.assertRaises(ValueError) as error: 180 | mb.create_card(custom_json=card_info, collection_id=2) 181 | self.assertEqual(str(error.exception), 'Either the name or id of the table must be provided.') 182 | 183 | # check to make sure the cards were created in the right collection 184 | collection_IDs_of_created_cards = { i['collection_id'] for i in [res1, res2, res3] } 185 | self.assertEqual(collection_IDs_of_created_cards, set({2})) 186 | 187 | # add id of the created cards to the cleaup list to be taken care of by the tearDown method 188 | Metabase_API_Test.cleanup_objects['card'].extend([ res1['id'], res3['id'], res3['id'] ]) 189 | 190 | 191 | 192 | def test_create_collection(self): 193 | t = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') 194 | res = mb.create_collection(f'test_create_collection {t}', parent_collection_id=2, return_results=True) 195 | 196 | # check to make sure the collection was created in the right place 197 | res2 = mb.get('/api/collection/{}'.format(res['id'])) 198 | self.assertEqual(res2['parent_id'], 2) 199 | 200 | # add to cleanup list 201 | Metabase_API_Test.cleanup_objects['collection'].append(res['id']) 202 | 203 | 204 | 205 | def test_copy_card(self): 206 | t = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') 207 | newCard_id = mb.copy_card(source_card_id=1, destination_collection_id=1, destination_card_name='test_copy_card_{}'.format(t)) 208 | 209 | # make sure the cards were created in the right collection 210 | res = mb.get('/api/card/{}'.format(newCard_id)) 211 | self.assertEqual(res['collection_id'], 1) 212 | 213 | # add to cleanup list 214 | Metabase_API_Test.cleanup_objects['card'].append(res['id']) 215 | 216 | 217 | 218 | # def test_copy_dashboard(self): 219 | # t = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') 220 | 221 | # # shallow copy 222 | # dup_dashboard_id_shallow = mb.copy_dashboard(source_dashboard_id=1, destination_collection_id=1, postfix='_dup_shallow_{}'.format(t)) 223 | 224 | # # deep copy 225 | # dup_dashboard_id_deep = mb.copy_dashboard(source_dashboard_id=1, destination_collection_id=1, postfix='_dup_deep_{}'.format(t), deepcopy=True) 226 | # new_collection_id = mb.get_item_id('collection', "test_dashboard_dup_deep_{}'s cards".format(t)) 227 | 228 | # # add to cleanup list 229 | # Metabase_API_Test.cleanup_objects['dashboard'].extend([dup_dashboard_id_shallow, dup_dashboard_id_deep]) 230 | # Metabase_API_Test.cleanup_objects['collection'].append(new_collection_id) 231 | 232 | 233 | 234 | def test_copy_collection(self): 235 | t = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') 236 | mb.copy_collection(source_collection_id=3, destination_parent_collection_id=1, destination_collection_name='test_copy_collection_{}'.format(t)) 237 | new_collection_id = mb.get_item_id('collection', 'test_copy_collection_{}'.format(t)) 238 | 239 | # add to cleanup list 240 | Metabase_API_Test.cleanup_objects['collection'].append(new_collection_id) 241 | 242 | 243 | 244 | def test_search(self): 245 | res = mb.search('test_db') 246 | self.assertEqual(len(res), 1) 247 | self.assertEqual(res[0]['model'], 'database') 248 | 249 | 250 | 251 | def test_get_card_data(self): 252 | # json 253 | res = mb.get_card_data(card_id=1) 254 | json_data = [ 255 | {'col1': 'row1 cell1', 'col2': 1}, 256 | {'col1': None, 'col2': 2}, 257 | {'col1': 'row3 cell1', 'col2': None}, 258 | {'col1': None, 'col2': None}, 259 | {'col1': 'row5 cell1', 'col2': 5} 260 | ] 261 | self.assertEqual(res, json_data) 262 | 263 | # formatted rows (in json export mode, Null values in varchar columns become '' and numbers become strings, e.g. 123 -> '123') 264 | res = mb.get_card_data(card_id=1, format_rows=True) 265 | json_data = [ 266 | {'col1': 'row1 cell1', 'col2': '1'}, 267 | {'col1': '', 'col2': '2'}, 268 | {'col1': 'row3 cell1', 'col2': None}, 269 | {'col1': '', 'col2': None}, 270 | {'col1': 'row5 cell1', 'col2': '5'} 271 | ] 272 | self.assertEqual(res, json_data) 273 | 274 | # csv 275 | res = mb.get_card_data(card_id=1, data_format='csv') 276 | csv_data = 'col1,col2\nrow1 cell1,1\n,2\nrow3 cell1,\n,\nrow5 cell1,5\n' 277 | self.assertEqual(res, csv_data) 278 | 279 | # filtered data 280 | res = mb.get_card_data(card_id=2, parameters=[{"type":"string/=","value":['row1 cell1', 'row3 cell1'],"target":["dimension",["template-tag","test_filter"]]}]) 281 | filtered_data = [{'col1': 'row1 cell1', 'col2': 1}, {'col1': 'row3 cell1', 'col2': None}] 282 | self.assertEqual(res, filtered_data) 283 | 284 | 285 | 286 | def test_clone_card(self): 287 | # native question 288 | res = mb.clone_card(2, 9, 10, new_card_name='test_clone_native', new_card_collection_id=1, return_card=True) 289 | # simple/custom question 290 | res2 = mb.clone_card(3, 9, 10, new_card_name='test_clone_simple1', new_card_collection_id=1, return_card=True) 291 | res3 = mb.clone_card(4, 9, 10, new_card_name='test_clone_simple2', new_card_collection_id=1, return_card=True) 292 | 293 | # rewriting a value because it's not reliable 294 | res3['dataset_query']['query']['order-by'] = '' 295 | 296 | expected_res3_query_version1 = { 'database': 2, 297 | 'query': {'source-table': 10, 298 | 'aggregation': [['avg', ['field', 75, None]]], 299 | 'breakout': [['field', 74, None]], 300 | 'order-by': '' 301 | }, 302 | 'type': 'query' 303 | } 304 | # we have two versions because the assigned field id is not necessarily in the order of columns as they appear in db 305 | expected_res3_query_version2 = { 'database': 2, 306 | 'query': {'source-table': 10, 307 | 'aggregation': [['avg', ['field', 74, None]]], 308 | 'breakout': [['field', 75, None]], 309 | 'order-by': '' 310 | }, 311 | 'type': 'query' 312 | } 313 | 314 | self.assertTrue(res3['dataset_query'] == expected_res3_query_version1 or res3['dataset_query'] == expected_res3_query_version2, res3['dataset_query']) 315 | 316 | # add to cleanup list 317 | Metabase_API_Test.cleanup_objects['card'].extend([res['id'], res2['id'], res3['id']]) 318 | 319 | 320 | 321 | def test_update_column(self): 322 | mb.update_column(params={'semantic_type':'type/City'}, column_id=72) 323 | res = mb.get('/api/field/72')['semantic_type'] 324 | self.assertEqual(res, 'type/City') 325 | 326 | mb.update_column(params={'semantic_type':'type/Category'}, column_id=72) 327 | res = mb.get('/api/field/72')['semantic_type'] 328 | self.assertEqual(res, 'type/Category') 329 | 330 | 331 | 332 | if __name__ == '__main__': 333 | unittest.main() 334 | --------------------------------------------------------------------------------