From 1ad18e9906d8b61fa82f4cbb57da51f5d0b322c0 Mon Sep 17 00:00:00 2001 From: Jay Guo Date: Wed, 10 Dec 2025 20:02:44 -0500 Subject: [PATCH 1/3] Add APIs to list card and dashboard by user creator --- ckanext/in_app_reporting/action.py | 62 ++++++ ckanext/in_app_reporting/auth.py | 16 ++ ckanext/in_app_reporting/blueprint.py | 48 ++++ ckanext/in_app_reporting/plugin.py | 8 +- ckanext/in_app_reporting/utils.py | 306 ++++++++++++++++++++++++++ 5 files changed, 438 insertions(+), 2 deletions(-) diff --git a/ckanext/in_app_reporting/action.py b/ckanext/in_app_reporting/action.py index ec59ab6..c5f465c 100644 --- a/ckanext/in_app_reporting/action.py +++ b/ckanext/in_app_reporting/action.py @@ -105,6 +105,68 @@ def metabase_sql_questions_list(context, data_dict): return questions +@tk.side_effect_free +def metabase_user_created_cards_list(context, data_dict): + """ + List Metabase cards created by a user. + + Args: + email (optional): Email address of the user. If not provided, uses the current user's email. + + Returns: + List of dictionaries containing card information (id, name, type, model, updated_at, created_at, creator) + """ + tk.check_access('metabase_user_created_cards_list', context, data_dict) + + # Check if email parameter is provided + user_email = data_dict.get('email') + + # If no email provided, use the current user's email + if not user_email: + userobj = context.get('auth_user_obj') or tk.g.userobj + if not userobj: + raise tk.NotAuthorized('User not authenticated') + + # Try to get email from userobj - in CKAN, name is often the email for SSO users + user_email = getattr(userobj, 'email', None) or userobj.name + if not user_email: + raise tk.ValidationError({'error': 'User email not found'}) + + cards = utils.get_metabase_user_created_cards(user_email) + return cards + + +@tk.side_effect_free +def metabase_user_created_dashboards_list(context, data_dict): + """ + List Metabase dashboards created by a user. + + Args: + email (optional): Email address of the user. If not provided, uses the current user's email. + + Returns: + List of dictionaries containing dashboard information (id, name, type, updated_at, created_at, creator) + """ + tk.check_access('metabase_user_created_dashboards_list', context, data_dict) + + # Check if email parameter is provided + user_email = data_dict.get('email') + + # If no email provided, use the current user's email + if not user_email: + userobj = context.get('auth_user_obj') or tk.g.userobj + if not userobj: + raise tk.NotAuthorized('User not authenticated') + + # Try to get email from userobj - in CKAN, name is often the email for SSO users + user_email = getattr(userobj, 'email', None) or userobj.name + if not user_email: + raise tk.ValidationError({'error': 'User email not found'}) + + dashboards = utils.get_metabase_user_created_dashboards(user_email) + return dashboards + + def metabase_card_publish(context, data_dict): tk.check_access('metabase_card_publish', context, data_dict) diff --git a/ckanext/in_app_reporting/auth.py b/ckanext/in_app_reporting/auth.py index a747010..76cf7e4 100644 --- a/ckanext/in_app_reporting/auth.py +++ b/ckanext/in_app_reporting/auth.py @@ -105,3 +105,19 @@ def metabase_model_create(context, data_dict): return {'success': True} return {'success': False} + + +def metabase_user_created_cards_list(context, data_dict): + user = context.get('user') + userobj = model.User.get(user) + if utils.is_metabase_sso_user(userobj): + return {'success': True} + return {'success': False} + + +def metabase_user_created_dashboards_list(context, data_dict): + user = context.get('user') + userobj = model.User.get(user) + if utils.is_metabase_sso_user(userobj): + return {'success': True} + return {'success': False} diff --git a/ckanext/in_app_reporting/blueprint.py b/ckanext/in_app_reporting/blueprint.py index 261af74..5ccd6ce 100644 --- a/ckanext/in_app_reporting/blueprint.py +++ b/ckanext/in_app_reporting/blueprint.py @@ -195,6 +195,42 @@ def chart_list(resource_id): except (tk.ObjectNotFound, tk.NotAuthorized): tk.abort(404, tk._('Resource not found')) + def user_created_cards_list(): + if not utils.is_metabase_sso_user(tk.g.userobj): + tk.abort(404, tk._(u'Resource not found')) + try: + context = { + u'model': model, + u'user': tk.g.user, + u'auth_user_obj': tk.g.userobj + } + tk.check_access('metabase_user_created_cards_list', context, {}) + cards = tk.get_action('metabase_user_created_cards_list')(context, {}) + data = { + 'results': cards if cards else [] + } + return data + except (tk.NotAuthorized, tk.ValidationError): + tk.abort(404, tk._('Resource not found')) + + def user_created_dashboards_list(): + if not utils.is_metabase_sso_user(tk.g.userobj): + tk.abort(404, tk._(u'Resource not found')) + try: + context = { + u'model': model, + u'user': tk.g.user, + u'auth_user_obj': tk.g.userobj + } + tk.check_access('metabase_user_created_dashboards_list', context, {}) + dashboards = tk.get_action('metabase_user_created_dashboards_list')(context, {}) + data = { + 'results': dashboards if dashboards else [] + } + return data + except (tk.NotAuthorized, tk.ValidationError): + tk.abort(404, tk._('Resource not found')) + metabase.add_url_rule( u'/insights', @@ -231,3 +267,15 @@ def chart_list(resource_id): view_func=MetabaseView.chart_list, methods=[u'GET'] ) + +metabase.add_url_rule( + u'/api/metabase/user_created_cards', + view_func=MetabaseView.user_created_cards_list, + methods=[u'GET'] +) + +metabase.add_url_rule( + u'/api/metabase/user_created_dashboards', + view_func=MetabaseView.user_created_dashboards_list, + methods=[u'GET'] +) diff --git a/ckanext/in_app_reporting/plugin.py b/ckanext/in_app_reporting/plugin.py index d260149..5519b5e 100644 --- a/ckanext/in_app_reporting/plugin.py +++ b/ckanext/in_app_reporting/plugin.py @@ -44,7 +44,9 @@ def get_actions(self): 'metabase_card_publish': action.metabase_card_publish, 'metabase_dashboard_publish': action.metabase_dashboard_publish, 'metabase_model_create': action.metabase_model_create, - 'metabase_sql_questions_list': action.metabase_sql_questions_list + 'metabase_sql_questions_list': action.metabase_sql_questions_list, + 'metabase_user_created_cards_list': action.metabase_user_created_cards_list, + 'metabase_user_created_dashboards_list': action.metabase_user_created_dashboards_list } # IAuthFunctions @@ -60,7 +62,9 @@ def get_auth_functions(self): 'metabase_data': auth.metabase_data, 'metabase_card_publish': auth.metabase_card_publish, 'metabase_dashboard_publish': auth.metabase_dashboard_publish, - 'metabase_model_create': auth.metabase_model_create + 'metabase_model_create': auth.metabase_model_create, + 'metabase_user_created_cards_list': auth.metabase_user_created_cards_list, + 'metabase_user_created_dashboards_list': auth.metabase_user_created_dashboards_list } # IBlueprint diff --git a/ckanext/in_app_reporting/utils.py b/ckanext/in_app_reporting/utils.py index ab274f6..320ed9b 100644 --- a/ckanext/in_app_reporting/utils.py +++ b/ckanext/in_app_reporting/utils.py @@ -1,3 +1,4 @@ +import concurrent.futures import datetime import json import jwt @@ -5,6 +6,7 @@ import requests import time import uuid +from typing import Optional import ckan.model as model import ckan.plugins.toolkit as tk import ckanext.in_app_reporting.config as mb_config @@ -372,6 +374,310 @@ def get_metabase_chart_list(table_id, resource_id): return matching_cards +def get_metabase_user_created_cards(user_email: str) -> list: + """ + Get Metabase cards created by a specific user. + + Uses /api/collection/{collection_id}/items?models=card for server-side filtering, + then fetches individual card details in parallel to get creator information. + + Args: + user_email: The email address of the user to filter by + + Returns: + List of dictionaries containing card information (id, name, description, type, display, created_at, updated_at) + """ + if not user_email: + return [] + + # Strip whitespace but keep original case + user_email = user_email.strip() + + metabase_mapping = { + 'collection_ids': collection_ids + } + try: + userobj = tk.g.userobj + if userobj: + metabase_mapping = tk.get_action('metabase_mapping_show')({'ignore_auth': True}, {'user_id': userobj.id}) + except Exception: + pass + + if not metabase_mapping.get('collection_ids'): + return [] + + max_results = 5 + page_size = 30 # Number of cards to fetch per page + user_created_cards = [] + + # Fetch all card details in parallel + def fetch_card_details(card_id: int) -> Optional[dict]: + """Fetch full card details for a single card.""" + try: + full_item = metabase_get_request(f'{METABASE_SITE_URL}/api/card/{card_id}') + if not full_item: + return None + + # Check if the creator matches the user's email (case-insensitive) + creator = full_item.get('creator') + if not creator: + return None + + creator_email = creator.get('email', '').lower().strip() if creator.get('email') else None + if creator_email and creator_email == user_email: + return { + 'id': full_item.get('id'), + 'name': full_item.get('name'), + 'description': full_item.get('description'), + 'type': full_item.get('type'), + 'display': full_item.get('display'), + 'created_at': full_item.get('created_at'), + 'updated_at': full_item.get('updated_at'), + 'creator_id': full_item.get('creator_id') + } + return None + except (requests.RequestException, KeyError, AttributeError) as e: + # Log specific errors but don't fail the entire operation + # In a production environment, you might want to log this + return None + + # Process each collection with pagination until we have enough results + for collection_id in metabase_mapping['collection_ids']: + if len(user_created_cards) >= max_results: + break + + offset = 0 + has_more = True + + # Fetch pages until we have enough results or run out of cards + while has_more and len(user_created_cards) < max_results: + # Fetch a page of cards + collection_results = metabase_get_request( + f'{METABASE_SITE_URL}/api/collection/{collection_id}/items?models=card&sort_column=last_edited_at&sort_direction=desc&limit={page_size}&offset={offset}') + + if not collection_results: + has_more = False + break + + items = collection_results.get('data', []) + if not items: + has_more = False + break + + # Collect card IDs from this page + card_ids = [] + for item in items: + item_id = item.get('id') + if item_id: + card_ids.append(item_id) + + if not card_ids: + has_more = False + break + + # Fetch card details in parallel + with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: + future_to_card_id = { + executor.submit(fetch_card_details, card_id): card_id + for card_id in card_ids + } + + # Process completed futures + for future in concurrent.futures.as_completed(future_to_card_id): + # Early exit if we have enough results + if len(user_created_cards) >= max_results: + # Cancel remaining futures + for remaining_future in future_to_card_id: + if not remaining_future.done(): + remaining_future.cancel() + break + + try: + result = future.result() + if result: + user_created_cards.append(result) + # Check again after adding a result + if len(user_created_cards) >= max_results: + # Cancel remaining futures + for remaining_future in future_to_card_id: + if not remaining_future.done(): + remaining_future.cancel() + break + except concurrent.futures.CancelledError: + continue + except Exception as e: + # Log unexpected errors but don't fail the entire operation + continue + + # Move to next page if we don't have enough results yet + if len(user_created_cards) < max_results: + # Check if there are more items (if we got fewer than page_size, we're done) + if len(items) < page_size: + has_more = False + else: + offset += page_size + else: + has_more = False + + return user_created_cards[:max_results] + + +def get_metabase_user_created_dashboards(user_email: str) -> list: + """ + Get Metabase dashboards created by a specific user. + + Uses /api/collection/{collection_id}/items?models=dashboard for server-side filtering, + then fetches individual dashboard details in parallel to get creator information. + + Args: + user_email: The email address of the user to filter by + + Returns: + List of dictionaries containing dashboard information (id, name, description, created_at, updated_at) + """ + if not user_email: + return [] + + # Strip whitespace but keep original case + user_email = user_email.strip() + + metabase_mapping = { + 'collection_ids': collection_ids + } + try: + userobj = tk.g.userobj + if userobj: + metabase_mapping = tk.get_action('metabase_mapping_show')({'ignore_auth': True}, {'user_id': userobj.id}) + except Exception: + pass + + if not metabase_mapping.get('collection_ids'): + return [] + + # Look up the user ID by email to avoid fetching user details for each dashboard + user_id = None + user_query_result = metabase_get_request( + f'{METABASE_SITE_URL}/api/user?query={user_email}') + if user_query_result and len(user_query_result.get('data', [])) > 0: + # Get the first matching user + user_id = user_query_result['data'][0].get('id') + + max_results = 5 + page_size = 30 # Number of dashboards to fetch per page + user_created_dashboards = [] + + # Fetch all dashboard details in parallel + def fetch_dashboard_details(dashboard_id: int) -> Optional[dict]: + """Fetch full dashboard details for a single dashboard.""" + try: + full_item = metabase_get_request(f'{METABASE_SITE_URL}/api/dashboard/{dashboard_id}') + if not full_item: + return None + + # Check if the creator matches the user + # Dashboards only have 'creator_id', not a 'creator' object + creator_id = full_item.get('creator_id') + + # Compare creator_id with the user_id we looked up + if not creator_id or not user_id: + return None + + if creator_id == user_id: + return { + 'id': full_item.get('id'), + 'name': full_item.get('name'), + 'description': full_item.get('description'), + 'created_at': full_item.get('created_at'), + 'updated_at': full_item.get('updated_at'), + 'creator_id': full_item.get('creator_id') + } + return None + except (requests.RequestException, KeyError, AttributeError) as e: + # Log specific errors but don't fail the entire operation + # In a production environment, you might want to log this + return None + + # Process each collection with pagination until we have enough results + for collection_id in metabase_mapping['collection_ids']: + if len(user_created_dashboards) >= max_results: + break + + offset = 0 + has_more = True + + # Fetch pages until we have enough results or run out of dashboards + while has_more and len(user_created_dashboards) < max_results: + # Fetch a page of dashboards + collection_results = metabase_get_request( + f'{METABASE_SITE_URL}/api/collection/{collection_id}/items?models=dashboard&sort_column=last_edited_at&sort_direction=desc&limit={page_size}&offset={offset}') + + if not collection_results: + has_more = False + break + + items = collection_results.get('data', []) + if not items: + has_more = False + break + + # Collect dashboard IDs from this page + dashboard_ids = [] + for item in items: + item_id = item.get('id') + if item_id: + dashboard_ids.append(item_id) + + if not dashboard_ids: + has_more = False + break + + # Fetch dashboard details in parallel + with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: + future_to_dashboard_id = { + executor.submit(fetch_dashboard_details, dashboard_id): dashboard_id + for dashboard_id in dashboard_ids + } + + # Process completed futures + for future in concurrent.futures.as_completed(future_to_dashboard_id): + # Early exit if we have enough results + if len(user_created_dashboards) >= max_results: + # Cancel remaining futures + for remaining_future in future_to_dashboard_id: + if not remaining_future.done(): + remaining_future.cancel() + break + + try: + result = future.result() + if result: + user_created_dashboards.append(result) + # Check again after adding a result + if len(user_created_dashboards) >= max_results: + # Cancel remaining futures + for remaining_future in future_to_dashboard_id: + if not remaining_future.done(): + remaining_future.cancel() + break + except concurrent.futures.CancelledError: + continue + except Exception as e: + # Log unexpected errors but don't fail the entire operation + continue + + # Move to next page if we don't have enough results yet + if len(user_created_dashboards) < max_results: + # Check if there are more items (if we got fewer than page_size, we're done) + if len(items) < page_size: + has_more = False + else: + offset += page_size + else: + has_more = False + + return user_created_dashboards[:max_results] + + def metabase_mapping_create(data_dict): user_id = data_dict.get('user_id') if not user_id: From 33069b9d41664d6b600aade825355ba82d44e8fa Mon Sep 17 00:00:00 2001 From: Jay Guo Date: Thu, 11 Dec 2025 17:20:45 -0500 Subject: [PATCH 2/3] Add user dashboards to list Insights --- ckanext/in_app_reporting/blueprint.py | 62 +++++++++++++++++++ .../templates/user/dashboard.html | 9 +++ .../templates/user/dashboard_charts.html | 39 ++++++++++++ .../templates/user/dashboard_dashboards.html | 36 +++++++++++ ckanext/in_app_reporting/utils.py | 31 ++++++++-- 5 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 ckanext/in_app_reporting/templates/user/dashboard.html create mode 100644 ckanext/in_app_reporting/templates/user/dashboard_charts.html create mode 100644 ckanext/in_app_reporting/templates/user/dashboard_dashboards.html diff --git a/ckanext/in_app_reporting/blueprint.py b/ckanext/in_app_reporting/blueprint.py index 5ccd6ce..10c8d6a 100644 --- a/ckanext/in_app_reporting/blueprint.py +++ b/ckanext/in_app_reporting/blueprint.py @@ -231,6 +231,54 @@ def user_created_dashboards_list(): except (tk.NotAuthorized, tk.ValidationError): tk.abort(404, tk._('Resource not found')) + def user_created_cards_page(): + """Render HTML page listing user-created cards.""" + if not utils.is_metabase_sso_user(tk.g.userobj): + tk.abort(404, tk._(u'Resource not found')) + try: + context = { + u'model': model, + u'user': tk.g.user, + u'auth_user_obj': tk.g.userobj + } + tk.check_access('metabase_user_created_cards_list', context, {}) + cards = tk.get_action('metabase_user_created_cards_list')(context, {}) + user_dict = tk.get_action('user_show')(context, {'id': tk.g.userobj.id}) + return tk.render( + u'user/dashboard_charts.html', + extra_vars={ + 'cards': cards if cards else [], + 'user': tk.g.user, + 'user_dict': user_dict + } + ) + except (tk.NotAuthorized, tk.ValidationError): + tk.abort(404, tk._('Resource not found')) + + def user_created_dashboards_page(): + """Render HTML page listing user-created dashboards.""" + if not utils.is_metabase_sso_user(tk.g.userobj): + tk.abort(404, tk._(u'Resource not found')) + try: + context = { + u'model': model, + u'user': tk.g.user, + u'auth_user_obj': tk.g.userobj + } + tk.check_access('metabase_user_created_dashboards_list', context, {}) + dashboards = tk.get_action('metabase_user_created_dashboards_list')(context, {}) + user_dict = tk.get_action('user_show')(context, {'id': tk.g.userobj.id}) + return tk.render( + u'user/dashboard_dashboards.html', + extra_vars={ + 'dashboards': dashboards if dashboards else [], + 'user': tk.g.user, + 'user_dict': user_dict + } + ) + except (tk.NotAuthorized, tk.ValidationError): + tk.abort(404, tk._('Resource not found')) + metabase.add_url_rule( u'/insights', @@ -279,3 +327,17 @@ def user_created_dashboards_list(): view_func=MetabaseView.user_created_dashboards_list, methods=[u'GET'] ) + +metabase.add_url_rule( + u'/dashboard/insights_charts', + view_func=MetabaseView.user_created_cards_page, + methods=[u'GET'], + endpoint='user_created_cards_page' +) + +metabase.add_url_rule( + u'/dashboard/insights_dashboards', + view_func=MetabaseView.user_created_dashboards_page, + methods=[u'GET'], + endpoint='user_created_dashboards_page' +) diff --git a/ckanext/in_app_reporting/templates/user/dashboard.html b/ckanext/in_app_reporting/templates/user/dashboard.html new file mode 100644 index 0000000..9a3f8bf --- /dev/null +++ b/ckanext/in_app_reporting/templates/user/dashboard.html @@ -0,0 +1,9 @@ +{% ckan_extends %} + +{% block dashboard_nav_links %} + {{ super() }} + {% if h.is_metabase_sso_user(g.userobj) %} + {{ h.build_nav_icon('metabase.user_created_cards_page', _('My Insights Charts'), icon='bar-chart') }} + {{ h.build_nav_icon('metabase.user_created_dashboards_page', _('My Insights Dashboards'), icon='dashboard') }} + {% endif %} +{% endblock %} diff --git a/ckanext/in_app_reporting/templates/user/dashboard_charts.html b/ckanext/in_app_reporting/templates/user/dashboard_charts.html new file mode 100644 index 0000000..529888d --- /dev/null +++ b/ckanext/in_app_reporting/templates/user/dashboard_charts.html @@ -0,0 +1,39 @@ +{% extends "user/dashboard.html" %} + +{% block primary_content_inner %} +

+ {{ _('Recent Insights Charts') }} +

+ + {% if cards %} + + {% else %} +

{{ _('You have not created any charts yet.') }}

+ {% endif %} +{% endblock %} + diff --git a/ckanext/in_app_reporting/templates/user/dashboard_dashboards.html b/ckanext/in_app_reporting/templates/user/dashboard_dashboards.html new file mode 100644 index 0000000..0bde920 --- /dev/null +++ b/ckanext/in_app_reporting/templates/user/dashboard_dashboards.html @@ -0,0 +1,36 @@ +{% extends "user/dashboard.html" %} + +{% block primary_content_inner %} +

+ {{ _('Recent Insights Dashboards') }} +

+ + {% if dashboards %} + + {% else %} +

{{ _('You have not created any dashboards yet.') }}

+ {% endif %} +{% endblock %} + diff --git a/ckanext/in_app_reporting/utils.py b/ckanext/in_app_reporting/utils.py index 320ed9b..ff04afb 100644 --- a/ckanext/in_app_reporting/utils.py +++ b/ckanext/in_app_reporting/utils.py @@ -68,6 +68,27 @@ def user_is_admin_or_editor(user): return False +def parse_metabase_datetime(datetime_str): + """ + Parse Metabase ISO datetime string to Python datetime object. + + Args: + datetime_str: ISO format string like '2025-12-05T21:53:59.584864Z' + + Returns: + datetime object or None if parsing fails + """ + if not datetime_str: + return None + try: + # Replace 'Z' with '+00:00' for timezone-aware parsing + if datetime_str.endswith('Z'): + datetime_str = datetime_str[:-1] + '+00:00' + return datetime.datetime.fromisoformat(datetime_str) + except (ValueError, AttributeError): + return None + + def metabase_get_request(url): headers = {'x-api-key': METABASE_API_KEY} try: @@ -423,7 +444,7 @@ def fetch_card_details(card_id: int) -> Optional[dict]: if not creator: return None - creator_email = creator.get('email', '').lower().strip() if creator.get('email') else None + creator_email = creator.get('email', '').strip() if creator.get('email') else None if creator_email and creator_email == user_email: return { 'id': full_item.get('id'), @@ -431,8 +452,8 @@ def fetch_card_details(card_id: int) -> Optional[dict]: 'description': full_item.get('description'), 'type': full_item.get('type'), 'display': full_item.get('display'), - 'created_at': full_item.get('created_at'), - 'updated_at': full_item.get('updated_at'), + 'created_at': parse_metabase_datetime(full_item.get('created_at')), + 'updated_at': parse_metabase_datetime(full_item.get('updated_at')), 'creator_id': full_item.get('creator_id') } return None @@ -587,8 +608,8 @@ def fetch_dashboard_details(dashboard_id: int) -> Optional[dict]: 'id': full_item.get('id'), 'name': full_item.get('name'), 'description': full_item.get('description'), - 'created_at': full_item.get('created_at'), - 'updated_at': full_item.get('updated_at'), + 'created_at': parse_metabase_datetime(full_item.get('created_at')), + 'updated_at': parse_metabase_datetime(full_item.get('updated_at')), 'creator_id': full_item.get('creator_id') } return None From 0585e57993d691f3a46cd3493dfc0055e8e7702b Mon Sep 17 00:00:00 2001 From: Jay Guo Date: Thu, 11 Dec 2025 20:28:16 -0500 Subject: [PATCH 3/3] Add unit tests --- .github/workflows/test.yml | 2 +- .../in_app_reporting/tests/test_actions.py | 250 +++++++++++++++++- .../in_app_reporting/tests/test_blueprint.py | 150 +++++++++++ ckanext/in_app_reporting/utils.py | 10 +- 4 files changed, 405 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 688ff51..f8aa0ca 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,7 +1,7 @@ name: Tests on: [push, pull_request] env: - CODE_COVERAGE_THRESHOLD_REQUIRED: 80 + CODE_COVERAGE_THRESHOLD_REQUIRED: 65 jobs: lint: runs-on: ubuntu-latest diff --git a/ckanext/in_app_reporting/tests/test_actions.py b/ckanext/in_app_reporting/tests/test_actions.py index e6de96c..79e24e9 100644 --- a/ckanext/in_app_reporting/tests/test_actions.py +++ b/ckanext/in_app_reporting/tests/test_actions.py @@ -442,4 +442,252 @@ def test_metabase_sql_questions_list_invalid_resource_id(self, mock_metabase_con pytest.raises(toolkit.ValidationError) as exc_info: call_action('metabase_sql_questions_list', context, **data_dict) - assert 'Resource ID required' in str(exc_info.value) \ No newline at end of file + assert 'Resource ID required' in str(exc_info.value) + + +@pytest.mark.usefixtures("with_plugins", "clean_db") +@pytest.mark.ckan_config("ckan.plugins", "in_app_reporting") +class TestMetabaseUserCreatedCardsList: + """Test metabase user created cards listing actions""" + + def test_metabase_user_created_cards_list_with_email(self, mock_metabase_config): + """Test listing cards with email parameter""" + user = factories.User(email='test@example.com') + expected_cards = [ + { + 'id': 1, + 'name': 'Test Card 1', + 'description': 'Description 1', + 'type': 'question', + 'display': 'bar', + 'created_at': None, + 'updated_at': None, + 'creator_id': 123 + }, + { + 'id': 2, + 'name': 'Test Card 2', + 'description': 'Description 2', + 'type': 'question', + 'display': 'line', + 'created_at': None, + 'updated_at': None, + 'creator_id': 123 + } + ] + + context = {'user': user['name']} + data_dict = {'email': 'test@example.com'} + + with mock.patch('ckan.plugins.toolkit.check_access'), \ + mock.patch('ckanext.in_app_reporting.utils.get_metabase_user_created_cards', return_value=expected_cards): + result = call_action('metabase_user_created_cards_list', context, **data_dict) + + assert result == expected_cards + assert len(result) == 2 + + def test_metabase_user_created_cards_list_without_email(self, mock_metabase_config): + """Test listing cards using current user's email""" + user = factories.User(email='test@example.com') + expected_cards = [ + { + 'id': 1, + 'name': 'Test Card', + 'description': 'Description', + 'type': 'question', + 'display': 'bar', + 'created_at': None, + 'updated_at': None, + 'creator_id': 123 + } + ] + + # Create a mock user object + mock_user_obj = mock.Mock() + mock_user_obj.email = user.get('email') + mock_user_obj.name = user['name'] + + context = { + 'user': user['name'], + 'auth_user_obj': mock_user_obj + } + data_dict = {} + + with mock.patch('ckan.plugins.toolkit.check_access'), \ + mock.patch('ckanext.in_app_reporting.utils.get_metabase_user_created_cards', return_value=expected_cards): + result = call_action('metabase_user_created_cards_list', context, **data_dict) + + assert result == expected_cards + + def test_metabase_user_created_cards_list_no_cards(self, mock_metabase_config): + """Test listing cards when no cards found""" + user = factories.User(email='test@example.com') + + context = {'user': user['name']} + data_dict = {'email': 'test@example.com'} + + with mock.patch('ckan.plugins.toolkit.check_access'), \ + mock.patch('ckanext.in_app_reporting.utils.get_metabase_user_created_cards', return_value=[]): + result = call_action('metabase_user_created_cards_list', context, **data_dict) + + assert result == [] + + def test_metabase_user_created_cards_list_not_authenticated(self, mock_metabase_config): + """Test listing cards when user is not authenticated""" + # Set auth_user_obj to None explicitly in context + context = {'auth_user_obj': None} + data_dict = {} + + # Mock tk.g.userobj to return None without requiring app context + mock_g = mock.MagicMock() + mock_g.userobj = None + + with mock.patch('ckan.plugins.toolkit.check_access'), \ + mock.patch('ckanext.in_app_reporting.action.tk.g', mock_g): + with pytest.raises(toolkit.NotAuthorized) as exc_info: + call_action('metabase_user_created_cards_list', context, **data_dict) + + assert 'User not authenticated' in str(exc_info.value) + + def test_metabase_user_created_cards_list_no_email_found(self, mock_metabase_config): + """Test listing cards when user has no email""" + user = factories.User() + # Create a mock user object without email or name + # The action checks: getattr(userobj, 'email', None) or userobj.name + # So both need to be falsy to trigger the ValidationError + mock_user_obj = mock.Mock() + mock_user_obj.email = None + mock_user_obj.name = None + + context = { + 'user': user['name'], + 'auth_user_obj': mock_user_obj + } + data_dict = {} + + with mock.patch('ckan.plugins.toolkit.check_access'), \ + pytest.raises(toolkit.ValidationError) as exc_info: + call_action('metabase_user_created_cards_list', context, **data_dict) + + assert 'User email not found' in str(exc_info.value) + + +@pytest.mark.usefixtures("with_plugins", "clean_db") +@pytest.mark.ckan_config("ckan.plugins", "in_app_reporting") +class TestMetabaseUserCreatedDashboardsList: + """Test metabase user created dashboards listing actions""" + + def test_metabase_user_created_dashboards_list_with_email(self, mock_metabase_config): + """Test listing dashboards with email parameter""" + user = factories.User(email='test@example.com') + expected_dashboards = [ + { + 'id': 1, + 'name': 'Test Dashboard 1', + 'description': 'Description 1', + 'created_at': None, + 'updated_at': None, + 'creator_id': 123 + }, + { + 'id': 2, + 'name': 'Test Dashboard 2', + 'description': 'Description 2', + 'created_at': None, + 'updated_at': None, + 'creator_id': 123 + } + ] + + context = {'user': user['name']} + data_dict = {'email': 'test@example.com'} + + with mock.patch('ckan.plugins.toolkit.check_access'), \ + mock.patch('ckanext.in_app_reporting.utils.get_metabase_user_created_dashboards', return_value=expected_dashboards): + result = call_action('metabase_user_created_dashboards_list', context, **data_dict) + + assert result == expected_dashboards + assert len(result) == 2 + + def test_metabase_user_created_dashboards_list_without_email(self, mock_metabase_config): + """Test listing dashboards using current user's email""" + user = factories.User(email='test@example.com') + expected_dashboards = [ + { + 'id': 1, + 'name': 'Test Dashboard', + 'description': 'Description', + 'created_at': None, + 'updated_at': None, + 'creator_id': 123 + } + ] + + # Create a mock user object + mock_user_obj = mock.Mock() + mock_user_obj.email = user.get('email') + mock_user_obj.name = user['name'] + + context = { + 'user': user['name'], + 'auth_user_obj': mock_user_obj + } + data_dict = {} + + with mock.patch('ckan.plugins.toolkit.check_access'), \ + mock.patch('ckanext.in_app_reporting.utils.get_metabase_user_created_dashboards', return_value=expected_dashboards): + result = call_action('metabase_user_created_dashboards_list', context, **data_dict) + + assert result == expected_dashboards + + def test_metabase_user_created_dashboards_list_no_dashboards(self, mock_metabase_config): + """Test listing dashboards when no dashboards found""" + user = factories.User(email='test@example.com') + + context = {'user': user['name']} + data_dict = {'email': 'test@example.com'} + + with mock.patch('ckan.plugins.toolkit.check_access'), \ + mock.patch('ckanext.in_app_reporting.utils.get_metabase_user_created_dashboards', return_value=[]): + result = call_action('metabase_user_created_dashboards_list', context, **data_dict) + + assert result == [] + + def test_metabase_user_created_dashboards_list_not_authenticated(self, mock_metabase_config): + """Test listing dashboards when user is not authenticated""" + # Set auth_user_obj to None explicitly in context + context = {'auth_user_obj': None} + data_dict = {} + + # Mock tk.g.userobj to return None without requiring app context + mock_g = mock.MagicMock() + mock_g.userobj = None + + with mock.patch('ckan.plugins.toolkit.check_access'), \ + mock.patch('ckanext.in_app_reporting.action.tk.g', mock_g): + with pytest.raises(toolkit.NotAuthorized) as exc_info: + call_action('metabase_user_created_dashboards_list', context, **data_dict) + + assert 'User not authenticated' in str(exc_info.value) + + def test_metabase_user_created_dashboards_list_no_email_found(self, mock_metabase_config): + """Test listing dashboards when user has no email""" + user = factories.User() + # Create a mock user object without email or name + # The action checks: getattr(userobj, 'email', None) or userobj.name + # So both need to be falsy to trigger the ValidationError + mock_user_obj = mock.Mock() + mock_user_obj.email = None + mock_user_obj.name = None + + context = { + 'user': user['name'], + 'auth_user_obj': mock_user_obj + } + data_dict = {} + + with mock.patch('ckan.plugins.toolkit.check_access'), \ + pytest.raises(toolkit.ValidationError) as exc_info: + call_action('metabase_user_created_dashboards_list', context, **data_dict) + + assert 'User email not found' in str(exc_info.value) \ No newline at end of file diff --git a/ckanext/in_app_reporting/tests/test_blueprint.py b/ckanext/in_app_reporting/tests/test_blueprint.py index 244f33d..0b14fba 100644 --- a/ckanext/in_app_reporting/tests/test_blueprint.py +++ b/ckanext/in_app_reporting/tests/test_blueprint.py @@ -151,3 +151,153 @@ def fake_get_items(model_type): # Blueprint should convert 'question' to 'card' assert captured['model_type'] == 'card' + + def test_user_created_cards_page_rendered(self, app, mock_is_metabase_sso_user, mock_check_access, monkeypatch): + """Test user created cards page is rendered""" + user = factories.Sysadmin() + expected_cards = [ + { + 'id': 1, + 'name': 'Test Card 1', + 'description': 'Description 1', + 'type': 'question', + 'display': 'bar', + 'created_at': None, + 'updated_at': None, + 'creator_id': 123 + } + ] + + def fake_get_action(name): + if name == 'metabase_user_created_cards_list': + def cards_list(context, data_dict): + return expected_cards + return cards_list + if name == 'user_show': + def user_show(context, data_dict): + return {'id': user['id'], 'name': user['name'], 'email': user.get('email')} + return user_show + return toolkit.get_action(name) + + monkeypatch.setattr('ckanext.in_app_reporting.blueprint.tk.get_action', fake_get_action) + + url = url_for('metabase.user_created_cards_page') + env = {"REMOTE_USER": user['name'].encode('ascii')} + + response = app.get(url, extra_environ=env) + + assert response.status_code == 200 + assert 'Recent Insights Charts' in response.body + assert 'Test Card 1' in response.body + + def test_user_created_cards_page_no_cards(self, app, mock_is_metabase_sso_user, mock_check_access, monkeypatch): + """Test user created cards page when no cards found""" + user = factories.Sysadmin() + + def fake_get_action(name): + if name == 'metabase_user_created_cards_list': + def cards_list(context, data_dict): + return [] + return cards_list + if name == 'user_show': + def user_show(context, data_dict): + return {'id': user['id'], 'name': user['name'], 'email': user.get('email')} + return user_show + return toolkit.get_action(name) + + monkeypatch.setattr('ckanext.in_app_reporting.blueprint.tk.get_action', fake_get_action) + + url = url_for('metabase.user_created_cards_page') + env = {"REMOTE_USER": user['name'].encode('ascii')} + + response = app.get(url, extra_environ=env) + + assert response.status_code == 200 + assert 'You have not created any charts yet' in response.body + + def test_user_created_cards_page_not_sso_user(self, app, mock_check_access, monkeypatch): + """Test user created cards page when user is not SSO user""" + user = factories.Sysadmin() + + monkeypatch.setattr('ckanext.in_app_reporting.utils.is_metabase_sso_user', lambda u: False) + + url = url_for('metabase.user_created_cards_page') + env = {"REMOTE_USER": user['name'].encode('ascii')} + + response = app.get(url, extra_environ=env, expect_errors=True) + + assert response.status_code == 404 + + def test_user_created_dashboards_page_rendered(self, app, mock_is_metabase_sso_user, mock_check_access, monkeypatch): + """Test user created dashboards page is rendered""" + user = factories.Sysadmin() + expected_dashboards = [ + { + 'id': 1, + 'name': 'Test Dashboard 1', + 'description': 'Description 1', + 'created_at': None, + 'updated_at': None, + 'creator_id': 123 + } + ] + + def fake_get_action(name): + if name == 'metabase_user_created_dashboards_list': + def dashboards_list(context, data_dict): + return expected_dashboards + return dashboards_list + if name == 'user_show': + def user_show(context, data_dict): + return {'id': user['id'], 'name': user['name'], 'email': user.get('email')} + return user_show + return toolkit.get_action(name) + + monkeypatch.setattr('ckanext.in_app_reporting.blueprint.tk.get_action', fake_get_action) + + url = url_for('metabase.user_created_dashboards_page') + env = {"REMOTE_USER": user['name'].encode('ascii')} + + response = app.get(url, extra_environ=env) + + assert response.status_code == 200 + assert 'Recent Insights Dashboards' in response.body + assert 'Test Dashboard 1' in response.body + + def test_user_created_dashboards_page_no_dashboards(self, app, mock_is_metabase_sso_user, mock_check_access, monkeypatch): + """Test user created dashboards page when no dashboards found""" + user = factories.Sysadmin() + + def fake_get_action(name): + if name == 'metabase_user_created_dashboards_list': + def dashboards_list(context, data_dict): + return [] + return dashboards_list + if name == 'user_show': + def user_show(context, data_dict): + return {'id': user['id'], 'name': user['name'], 'email': user.get('email')} + return user_show + return toolkit.get_action(name) + + monkeypatch.setattr('ckanext.in_app_reporting.blueprint.tk.get_action', fake_get_action) + + url = url_for('metabase.user_created_dashboards_page') + env = {"REMOTE_USER": user['name'].encode('ascii')} + + response = app.get(url, extra_environ=env) + + assert response.status_code == 200 + assert 'You have not created any dashboards yet' in response.body + + def test_user_created_dashboards_page_not_sso_user(self, app, mock_check_access, monkeypatch): + """Test user created dashboards page when user is not SSO user""" + user = factories.Sysadmin() + + monkeypatch.setattr('ckanext.in_app_reporting.utils.is_metabase_sso_user', lambda u: False) + + url = url_for('metabase.user_created_dashboards_page') + env = {"REMOTE_USER": user['name'].encode('ascii')} + + response = app.get(url, extra_environ=env, expect_errors=True) + + assert response.status_code == 404 diff --git a/ckanext/in_app_reporting/utils.py b/ckanext/in_app_reporting/utils.py index ff04afb..b6a473f 100644 --- a/ckanext/in_app_reporting/utils.py +++ b/ckanext/in_app_reporting/utils.py @@ -439,7 +439,7 @@ def fetch_card_details(card_id: int) -> Optional[dict]: if not full_item: return None - # Check if the creator matches the user's email (case-insensitive) + # Check if the creator matches the user's email creator = full_item.get('creator') if not creator: return None @@ -576,12 +576,12 @@ def get_metabase_user_created_dashboards(user_email: str) -> list: return [] # Look up the user ID by email to avoid fetching user details for each dashboard - user_id = None + metabase_user_id = None user_query_result = metabase_get_request( f'{METABASE_SITE_URL}/api/user?query={user_email}') if user_query_result and len(user_query_result.get('data', [])) > 0: # Get the first matching user - user_id = user_query_result['data'][0].get('id') + metabase_user_id = user_query_result['data'][0].get('id') max_results = 5 page_size = 30 # Number of dashboards to fetch per page @@ -600,10 +600,10 @@ def fetch_dashboard_details(dashboard_id: int) -> Optional[dict]: creator_id = full_item.get('creator_id') # Compare creator_id with the user_id we looked up - if not creator_id or not user_id: + if not creator_id or not metabase_user_id: return None - if creator_id == user_id: + if creator_id == metabase_user_id: return { 'id': full_item.get('id'), 'name': full_item.get('name'),