From 5bc49e09efa0bbfc7e919335804c202e54d9bd6a Mon Sep 17 00:00:00 2001 From: Charles Larivier Date: Sun, 5 Dec 2021 14:47:48 -0500 Subject: [PATCH 1/5] feat: add Card Signed-off-by: Charles Larivier --- src/metabase/__init__.py | 1 + src/metabase/resource.py | 2 +- src/metabase/resources/card.py | 136 +++++++++++++++++++++++++++++++++ tests/resources/test_card.py | 120 +++++++++++++++++++++++++++++ tests/test_resource.py | 1 + 5 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 src/metabase/resources/card.py create mode 100644 tests/resources/test_card.py diff --git a/src/metabase/__init__.py b/src/metabase/__init__.py index f285c10..30dc4c7 100644 --- a/src/metabase/__init__.py +++ b/src/metabase/__init__.py @@ -1,4 +1,5 @@ from metabase.metabase import Metabase +from metabase.resources.card import Card from metabase.resources.database import Database from metabase.resources.dataset import Dataset from metabase.resources.field import Field diff --git a/src/metabase/resource.py b/src/metabase/resource.py index 20c76d9..4145d9a 100644 --- a/src/metabase/resource.py +++ b/src/metabase/resource.py @@ -81,7 +81,7 @@ def update(self, **kwargs) -> None: self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}", json=params ) - if response.status_code != 200: + if response.status_code not in (200, 202): raise HTTPError(response.json()) for k, v in kwargs.items(): diff --git a/src/metabase/resources/card.py b/src/metabase/resources/card.py new file mode 100644 index 0000000..1d436a7 --- /dev/null +++ b/src/metabase/resources/card.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from typing import List, Optional + +from metabase import User +from metabase.missing import MISSING +from metabase.resource import CreateResource, GetResource, ListResource, UpdateResource + + +class Card(ListResource, CreateResource, GetResource, UpdateResource): + ENDPOINT = "/api/card" + + id: int + table_id: int + database_id: int + collection_id: int + creator_id: int + made_public_by_id: int + public_uuid: str + + name: str + description: str + + collection: dict # TODO: Collection + collection_position: int + + query_type: str + dataset_query: dict # TODO: DatasetQuery + display: str + visualization_settings: dict # TODO: VisualizationSettings + result_metadata: List[dict] + + embedding_params: dict + cache_ttl: str + creator: User + + favorite: bool + archived: bool + enable_embedding: bool + + updated_at: str + created_at: str + + @classmethod + def list(cls) -> List[Card]: + """ + Get all the Cards. Option filter param f can be used to change the set + of Cards that are returned; default is all, but other options include + mine, fav, database, table, recent, popular, and archived. + + See corresponding implementation functions above for the specific behavior + of each filter option. :card_index:. + """ + # TODO: add support for endpoint parameters: f, model_id. + return super(Card, cls).list() + + @classmethod + def get(cls, id: int) -> Card: + """ + Get Card with ID. + """ + return super(Card, cls).get(id) + + @classmethod + def create( + cls, + name: str, + dataset_query: dict, # TODO: DatasetQuery + visualization_settings: dict, # TODO: VisualizationSettings + display: str, + description: str = None, + collection_id: str = None, + collection_position: int = None, + result_metadata: List[dict] = None, + metadata_checksum: str = None, + cache_ttl: int = None, + **kwargs, + ) -> Card: + """ + Create a new Card. + """ + return super(Card, cls).create( + name=name, + dataset_query=dataset_query, + visualization_settings=visualization_settings, + display=display, + description=description, + collection_id=collection_id, + collection_position=collection_position, + result_metadata=result_metadata, + metadata_checksum=metadata_checksum, + cache_ttl=cache_ttl, + **kwargs, + ) + + def update( + self, + name: str = MISSING, + dataset_query: dict = MISSING, # TODO: DatasetQuery + visualization_settings: dict = MISSING, # TODO: VisualizationSettings + display: str = MISSING, + description: str = MISSING, + collection_id: str = MISSING, + collection_position: int = MISSING, + result_metadata: List[dict] = MISSING, + metadata_checksum: str = MISSING, + archived: bool = MISSING, + enable_embedding: bool = MISSING, + embedding_params: dict = MISSING, + cache_ttl: int = None, + **kwargs, + ) -> None: + """ + Update a Card. + """ + return super(Card, self).update( + name=name, + dataset_query=dataset_query, + visualization_settings=visualization_settings, + display=display, + description=description, + collection_id=collection_id, + collection_position=collection_position, + result_metadata=result_metadata, + metadata_checksum=metadata_checksum, + archived=archived, + enable_embedding=enable_embedding, + embedding_params=embedding_params, + cache_ttl=cache_ttl, + ) + + def archive(self): + """Archive a Metric.""" + return self.update( + archived=True, revision_message="Archived by metabase-python." + ) diff --git a/tests/resources/test_card.py b/tests/resources/test_card.py new file mode 100644 index 0000000..baf4968 --- /dev/null +++ b/tests/resources/test_card.py @@ -0,0 +1,120 @@ +from metabase import Card +from tests.helpers import IntegrationTestCase + + +class CardTests(IntegrationTestCase): + def setUp(self) -> None: + super(CardTests, self).setUp() + + def test_import(self): + """Ensure Card can be imported from Metabase.""" + from metabase import Card + + self.assertIsNotNone(Card()) + + def test_list(self): + """Ensure Card.list() returns a list of Card instances.""" + cards = Card.list() + + self.assertIsInstance(cards, list) + self.assertTrue(len(cards) > 0) + self.assertTrue(all(isinstance(t, Card) for t in cards)) + + def test_get(self): + """Ensure Card.get() returns a Card instance for a given ID.""" + card = Card.get(1) + + self.assertIsInstance(card, Card) + self.assertEqual(1, card.id) + + def test_create(self): + """Ensure Card.create() creates a Card in Metabase and returns a Card instance.""" + card = Card.create( + name="My Card", + dataset_query={ + "type": "query", + "query": { + "source-table": 2, + "aggregation": [["count"]], + "breakout": [["field", 12, {"temporal-unit": "month"}]], + }, + "database": 1, + }, + visualization_settings={ + "graph.dimensions": ["CREATED_AT"], + "graph.metrics": ["count"], + }, + display="line", + ) + + self.assertIsInstance(card, Card) + self.assertEqual("My Card", card.name) + self.assertEqual("line", card.display) + self.assertIsInstance(Card.get(card.id), Card) # instance exists in Metabase + + # teardown + card.archive() + + def test_update(self): + """Ensure Card.update() updates an existing Card in Metabase.""" + # fixture + card = Card.create( + name="My Card", + dataset_query={ + "type": "query", + "query": { + "source-table": 2, + "aggregation": [["count"]], + "breakout": [["field", 12, {"temporal-unit": "month"}]], + }, + "database": 1, + }, + visualization_settings={ + "graph.dimensions": ["CREATED_AT"], + "graph.metrics": ["count"], + }, + display="line", + ) + + card = Card.get(1) + + name = card.name + card.update(name="New Name") + + # assert local instance is mutated + self.assertEqual("New Name", card.name) + + # assert metabase object is mutated + t = Card.get(card.id) + self.assertEqual("New Name", t.name) + + # teardown + t.archive() + + def test_archive(self): + """Ensure Card.archive() deletes a Card in Metabase.""" + # fixture + card = Card.create( + name="My Card", + dataset_query={ + "type": "query", + "query": { + "source-table": 2, + "aggregation": [["count"]], + "breakout": [["field", 12, {"temporal-unit": "month"}]], + }, + "database": 1, + }, + visualization_settings={ + "graph.dimensions": ["CREATED_AT"], + "graph.metrics": ["count"], + }, + display="line", + ) + self.assertIsInstance(card, Card) + + card.archive() + self.assertEqual(True, card.archived) + + c = Card.get(card.id) + self.assertEqual(True, c.archived) diff --git a/tests/test_resource.py b/tests/test_resource.py index 055ef9e..89071ce 100644 --- a/tests/test_resource.py +++ b/tests/test_resource.py @@ -6,6 +6,7 @@ from metabase.exceptions import NotFoundError from metabase.missing import MISSING from metabase.resource import ( + ArchiveResource, CreateResource, DeleteResource, GetResource, From 091904b193f12a975ec4c331fc647ebc1fec6503 Mon Sep 17 00:00:00 2001 From: Charles Larivier Date: Sun, 5 Dec 2021 14:51:47 -0500 Subject: [PATCH 2/5] docs: add Card Signed-off-by: Charles Larivier --- README.md | 74 +++++++++++++++++++++++++++---------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 5bdb7c7..0421025 100644 --- a/README.md +++ b/README.md @@ -111,43 +111,43 @@ df = dataset.to_pandas() For a full list of endpoints and methods, see [Metabase API](https://www.metabase.com/docs/latest/api-documentation.html). -| Endpoints | Support | -|-----------------------|:----------:| -| Activity | ❌ | -| Alert | ❌ | -| Automagic dashboards | ❌ | -| Card | ❌ | -| Collection | ❌ | -| Dashboard | ❌ | -| Database | ✅ | -| Dataset | ✅ | -| Email | ❌ | -| Embed | ❌ | -| Field | ✅ | -| Geojson | ❌ | -| Ldap | ❌ | -| Login history | ❌ | -| Metric | ✅ | -| Native query snippet | ❌ | -| Notify | ❌ | -| Permissions | ❌ | -| Premium features | ❌ | -| Preview embed | ❌ | -| Public | ❌ | -| Pulse | ❌ | -| Revision | ❌ | -| Search | ❌ | -| Segment | ✅ | -| Session | ❌ | -| Setting | ❌ | -| Setup | ❌ | -| Slack | ❌ | -| Table | ✅ | -| Task | ❌ | -| Tiles | ❌ | -| Transform | ❌ | -| User | ✅ | -| Util | ❌ | +| Endpoints | Support | Notes | +|-----------------------|:----------:|-------| +| Activity | ❌ | | +| Alert | ❌ | | +| Automagic dashboards | ❌ | | +| Card | ⚠️ | Partial support; list/create/update/archive. Missing additional functionality (i.e. POST /api/card/:card-id:/favorite | +| Collection | ❌ | | +| Dashboard | ❌ | | +| Database | ✅ | | +| Dataset | ✅ | | +| Email | ❌ | | +| Embed | ❌ | | +| Field | ✅ | | +| Geojson | ❌ | | +| Ldap | ❌ | | +| Login history | ❌ | | +| Metric | ✅ | | +| Native query snippet | ❌ | | +| Notify | ❌ | | +| Permissions | ❌ | | +| Premium features | ❌ | | +| Preview embed | ❌ | | +| Public | ❌ | | +| Pulse | ❌ | | +| Revision | ❌ | | +| Search | ❌ | | +| Segment | ✅ | | +| Session | ❌ | | +| Setting | ❌ | | +| Setup | ❌ | | +| Slack | ❌ | | +| Table | ✅ | | +| Task | ❌ | | +| Tiles | ❌ | | +| Transform | ❌ | | +| User | ✅ | | +| Util | ❌ | | ## Contributing Contributions are welcome! From f0edad869f3bdb669edd604215b4b2a638a6da31 Mon Sep 17 00:00:00 2001 From: Charles Larivier Date: Sun, 5 Dec 2021 15:00:26 -0500 Subject: [PATCH 3/5] fix: remove ArchiveResource import statement Signed-off-by: Charles Larivier --- tests/test_resource.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_resource.py b/tests/test_resource.py index 89071ce..055ef9e 100644 --- a/tests/test_resource.py +++ b/tests/test_resource.py @@ -6,7 +6,6 @@ from metabase.exceptions import NotFoundError from metabase.missing import MISSING from metabase.resource import ( - ArchiveResource, CreateResource, DeleteResource, GetResource, From 393d7b022629165d7f29c0cc58b6d455f1c6204d Mon Sep 17 00:00:00 2001 From: Charles Larivier Date: Sun, 5 Dec 2021 15:00:36 -0500 Subject: [PATCH 4/5] fix: circular import Signed-off-by: Charles Larivier --- src/metabase/resources/card.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/metabase/resources/card.py b/src/metabase/resources/card.py index 1d436a7..f4c70d9 100644 --- a/src/metabase/resources/card.py +++ b/src/metabase/resources/card.py @@ -1,8 +1,7 @@ from __future__ import annotations -from typing import List, Optional +from typing import List -from metabase import User from metabase.missing import MISSING from metabase.resource import CreateResource, GetResource, ListResource, UpdateResource @@ -32,7 +31,7 @@ class Card(ListResource, CreateResource, GetResource, UpdateResource): embedding_params: dict cache_ttl: str - creator: User + creator: "User" favorite: bool archived: bool From 4c236b49ea66a22033d3b2719a041449dbb19d5b Mon Sep 17 00:00:00 2001 From: Charles Larivier Date: Sun, 5 Dec 2021 15:06:00 -0500 Subject: [PATCH 5/5] fix: add fixture in CardTests.test_list() Signed-off-by: Charles Larivier --- tests/resources/test_card.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/resources/test_card.py b/tests/resources/test_card.py index baf4968..36c973f 100644 --- a/tests/resources/test_card.py +++ b/tests/resources/test_card.py @@ -14,6 +14,25 @@ def test_import(self): def test_list(self): """Ensure Card.list() returns a list of Card instances.""" + # fixture + card = Card.create( + name="My Card", + dataset_query={ + "type": "query", + "query": { + "source-table": 2, + "aggregation": [["count"]], + "breakout": [["field", 12, {"temporal-unit": "month"}]], + }, + "database": 1, + }, + visualization_settings={ + "graph.dimensions": ["CREATED_AT"], + "graph.metrics": ["count"], + }, + display="line", + ) + cards = Card.list() self.assertIsInstance(cards, list)