diff --git a/frontend/src2/charts/chart.ts b/frontend/src2/charts/chart.ts index 8618c7cf6..a259cb23a 100644 --- a/frontend/src2/charts/chart.ts +++ b/frontend/src2/charts/chart.ts @@ -52,7 +52,7 @@ function makeChart(name: string) { if (!chart.isloaded) return {} as Query return useQuery(chart.doc.data_query) }) - async function refresh(force?: boolean, reload?: boolean) { + async function refresh(force?: boolean, reload?: boolean, dashboard_name?: string) { if (reload) { await chart.load() } @@ -61,10 +61,19 @@ function makeChart(name: string) { () => chart.isloaded && dataQuery.value.isloaded && useQuery(chart.doc.query).isloaded ) + if (dashboard_name) { + dataQuery.value.dashboardName = dashboard_name + } + const isValid = validateConfig() if (!isValid) return const query = useQuery('new-query-' + getUniqueId()) + + if (dataQuery.value.dashboardName) { + query.dashboardName = dataQuery.value.dashboardName + } + addSourceOperation(query) addFilterOperation(query) addChartOperation(query) @@ -84,7 +93,10 @@ function makeChart(name: string) { dataQuery.value.setOperations(copy(query.doc.operations)) dataQuery.value.doc.use_live_connection = query.doc.use_live_connection - return dataQuery.value.execute(force) + if (query.dashboardName) { + dataQuery.value.dashboardName = query.dashboardName + } + return dataQuery.value.execute(force, query.dashboardName) } function validateConfig() { diff --git a/frontend/src2/charts/components/DrillDown.vue b/frontend/src2/charts/components/DrillDown.vue index 14a22aa91..e234b1516 100644 --- a/frontend/src2/charts/components/DrillDown.vue +++ b/frontend/src2/charts/components/DrillDown.vue @@ -31,7 +31,11 @@ wheneverChanges( if (show.value) { isQueryReady.value = false nextTick(async () => { - await props.query.execute(true) + const dashboardName = dashboard?.doc?.name + if (dashboardName) { + props.query.dashboardName = dashboardName + } + await props.query.execute(true, dashboardName) isQueryReady.value = true }) } diff --git a/frontend/src2/dashboard/dashboard.ts b/frontend/src2/dashboard/dashboard.ts index ed482189b..fcb5db563 100644 --- a/frontend/src2/dashboard/dashboard.ts +++ b/frontend/src2/dashboard/dashboard.ts @@ -210,7 +210,7 @@ function makeDashboard(name: string) { function refreshChart(chart_name: string, force = false) { const chart = useChart(chart_name) chart.dataQuery.adhocFilters = getAdhocFilters(chart_name) - chart.refresh(force) + chart.refresh(force, false, dashboard.doc.name) } function getAdhocFilters(chart_name: string) { @@ -307,6 +307,7 @@ function makeDashboard(name: string) { column_name: column, search_term, adhoc_filters: adhocFilters, + dashboard_name: dashboard.doc.name }) } diff --git a/frontend/src2/query/query.ts b/frontend/src2/query/query.ts index 32c9c3ea0..c5a3e422a 100644 --- a/frontend/src2/query/query.ts +++ b/frontend/src2/query/query.ts @@ -129,7 +129,13 @@ export function makeQuery(name: string) { } const adhocFilters = ref() - async function execute(force: boolean = false) { + const dashboardName = ref() + async function execute(force: boolean = false, dashboard_name?: string) { + + if (dashboard_name) { + dashboardName.value = dashboard_name + } + if (!query.islocal) { await waitUntil(() => query.isloaded) } @@ -156,6 +162,7 @@ export function makeQuery(name: string) { active_operation_idx: activeOperationIdx.value, adhoc_filters: adhocFilters.value, force, + dashboard_name: dashboardName.value, }) .then((response: any) => { if (!response) return @@ -567,7 +574,7 @@ export function makeQuery(name: string) { currentDownloadToken.value = null } }) - } + } _downloadResults() } @@ -593,6 +600,7 @@ export function makeQuery(name: string) { column_name: column, search_term, limit, + dashboard_name: dashboardName.value }) } @@ -700,6 +708,11 @@ export function makeQuery(name: string) { const drill_down_query = useQuery('new-query-' + getUniqueId()) drill_down_query.doc.title = 'Drill Down' drill_down_query.doc.use_live_connection = query.doc.use_live_connection + + if (dashboardName.value) { + drill_down_query.dashboardName = dashboardName.value + } + drill_down_query.autoExecute = true let filters: FilterArgs[] = [] @@ -821,6 +834,11 @@ export function makeQuery(name: string) { ) { const drill_down_query = useQuery('new-query-' + getUniqueId()) drill_down_query.doc.title = 'Drill Down' + + if (dashboardName.value) { + drill_down_query.dashboardName = dashboardName.value + } + drill_down_query.autoExecute = true drill_down_query.doc.workbook = query.doc.workbook drill_down_query.doc.use_live_connection = query.doc.use_live_connection @@ -937,6 +955,7 @@ export function makeQuery(name: string) { currentOperations, activeEditOperation, adhocFilters, + dashboardName, autoExecute, executing, diff --git a/insights/insights/doctype/insights_dashboard_v3/insights_dashboard_v3.py b/insights/insights/doctype/insights_dashboard_v3/insights_dashboard_v3.py index 0bb508b5f..6742efe13 100644 --- a/insights/insights/doctype/insights_dashboard_v3/insights_dashboard_v3.py +++ b/insights/insights/doctype/insights_dashboard_v3/insights_dashboard_v3.py @@ -80,7 +80,7 @@ def set_linked_charts(self): ) @frappe.whitelist() - def get_distinct_column_values(self, query, column_name, search_term=None, adhoc_filters=None): + def get_distinct_column_values(self, query, column_name, search_term=None, adhoc_filters=None, dashboard_name=None): is_guest = frappe.session.user == "Guest" if is_guest and not self.is_public: raise frappe.PermissionError @@ -88,8 +88,9 @@ def get_distinct_column_values(self, query, column_name, search_term=None, adhoc self.check_linked_filters(query, column_name) doc = frappe.get_cached_doc("Insights Query v3", query) + dashboardname = dashboard_name or self.name return doc.get_distinct_column_values( - column_name, search_term=search_term, adhoc_filters=adhoc_filters + column_name, search_term=search_term, adhoc_filters=adhoc_filters, dashboard_name=dashboardname ) def check_linked_filters(self, query, column_name): diff --git a/insights/insights/doctype/insights_query_v3/insights_query_v3.py b/insights/insights/doctype/insights_query_v3/insights_query_v3.py index 002cbeca4..4a2e559c4 100644 --- a/insights/insights/doctype/insights_query_v3/insights_query_v3.py +++ b/insights/insights/doctype/insights_query_v3/insights_query_v3.py @@ -108,7 +108,21 @@ def build(self, active_operation_idx=None, use_live_connection=None): return ibis_query @frappe.whitelist() - def execute(self, active_operation_idx=None, adhoc_filters=None, force=False): + def execute(self, active_operation_idx=None, adhoc_filters=None, force=False, dashboard_name=None): + if dashboard_name: + from insights.permissions import InsightsPermissions + permissions = InsightsPermissions() + allowed_tables = permissions.get_dashboard_query_tables( + dashboard_name, + query_name=self.name + ) + if allowed_tables: + frappe.flags.permitted_dashboard_tables = allowed_tables + else: + return + else: + return + with set_adhoc_filters(adhoc_filters): ibis_query = self.build(active_operation_idx) @@ -186,7 +200,21 @@ def get_distinct_column_values( search_term=None, limit=20, adhoc_filters=None, + dashboard_name=None, ): + + if dashboard_name: + from insights.permissions import InsightsPermissions + permissions = InsightsPermissions() + allowed_tables = permissions.get_dashboard_query_tables( + dashboard_name=dashboard_name, + query_name=self.name + ) + if allowed_tables: + frappe.flags.permitted_dashboard_tables = allowed_tables + else: + return + with set_adhoc_filters(adhoc_filters): ibis_query = self.build(active_operation_idx) diff --git a/insights/insights/doctype/insights_team/insights_team.py b/insights/insights/doctype/insights_team/insights_team.py index b54f6f098..426fe03ea 100644 --- a/insights/insights/doctype/insights_team/insights_team.py +++ b/insights/insights/doctype/insights_team/insights_team.py @@ -269,16 +269,19 @@ def check_table_permission(data_source, table, user=None, raise_error=True): table_name = get_table_name(data_source, table) allowed_tables = get_allowed_resources_for_user("Insights Table v3", user) - if table_name not in allowed_tables: - if raise_error: - frappe.throw( - "You do not have permission to access this table", - exc=frappe.PermissionError, - ) - else: - return False + if table_name in allowed_tables: + return True - return True + permitted_tables = getattr(frappe.flags, "permitted_dashboard_tables", None) + if permitted_tables and table_name in permitted_tables: + return True + + if raise_error: + frappe.throw( + "You do not have permission to access this table", + exc=frappe.PermissionError, + ) + return False def get_table_restrictions(data_source, table, user=None): diff --git a/insights/permissions.py b/insights/permissions.py index 4bcf9435a..64ccd7a08 100644 --- a/insights/permissions.py +++ b/insights/permissions.py @@ -6,6 +6,8 @@ import frappe import frappe.share +from frappe import parse_json +from frappe.utils.caching import redis_cache from insights.insights.doctype.insights_team.insights_team import ( get_teams, @@ -101,7 +103,8 @@ def has_doc_permission(self, doc, ptype): return True docs = self._build_permission_query(doc.doctype, access_type) - return docs.where(frappe.qb.DocType(doc.doctype).name == doc.name).limit(1).run(pluck="name") + result = bool(docs.where(frappe.qb.DocType(doc.doctype).name == doc.name).limit(1).run(pluck="name")) + return result def _build_permission_query(self, doctype, ptype): """Returns a query to get docs with `ptype` permission""" @@ -386,6 +389,147 @@ def _build_resource_query(self, doctype): return frappe.qb.from_(Resource).select(Resource.resource_name.as_("name")).where(condition) + def get_dashboard_query_tables(self, dashboard_name, *args, **kwargs): + # return early if no read permission + if not frappe.has_permission("Insights Dashboard v3", doc=dashboard_name, ptype="read"): + return [] + + return self._get_dashboard_tables_cached(dashboard_name) + + @redis_cache(ttl=3600, shared=True) + def _get_dashboard_tables_cached(self, dashboard_name): + return self._get_dashboard_tables(dashboard_name) + + def _get_dashboard_tables(self, dashboard_name): + """Returns list of tables used in Dashboard queries""" + try: + Dashboard = frappe.qb.DocType("Insights Dashboard v3") + + dashboard_data = ( + frappe.qb.from_(Dashboard) + .select(Dashboard.items) + .where(Dashboard.name == dashboard_name) + .run(as_dict=True) + ) + + if not dashboard_data: + return [] + # get dashboard json + json = dashboard_data[0].get("items") + if not json: + return [] + print(f"json{json}") + items = parse_json(json) + + chart_names = [ + item.get("chart") for item in items if item.get("type") == "chart" and item.get("chart") + ] + + if not chart_names: + return [] + + # get dependent queries + Chart = frappe.qb.DocType("Insights Chart v3") + queries = ( + frappe.qb.from_(Chart) + .select(Chart.data_query, Chart.query) + .where(Chart.name.isin(chart_names)) + .run(as_dict=True) + ) + + query_names = set() + for q in queries: + if q.get("data_query"): + query_names.add(q.get("data_query")) + if q.get("query"): + query_names.add(q.get("query")) + print(f"query_names: {query_names}") + if not query_names: + return [] + + return self._get_tables_from_queries(list(query_names)) + + except Exception: + frappe.log_error(f"Error resolving tables for dashboard {dashboard_name}") + return [] + + def _get_tables_from_queries(self, query_names, visited=None): + + # Fix: check for visited to avoid infinite recursion + # because of circular dependences + if visited is None: + visited = set() + + query_names = [q for q in query_names if q not in visited] + if not query_names: + return [] + + visited.update(query_names) + + # fetch all query operations to extract table info + Query = frappe.qb.DocType("Insights Query v3") + query_operations = ( + frappe.qb.from_(Query) + .select(Query.operations) + .where(Query.name.isin(query_names)) + .run(pluck=True) + ) + + sources = set() + nested_queries = [] + + for operations_json in query_operations: + print(f"operations_json: {operations_json}") + if not operations_json: + continue + operations = parse_json(operations_json) + if not isinstance(operations, list): + continue + + for operation in operations: + if operation.get("type") != "source": + continue + + table_info = operation.get("table", {}) + _type = table_info.get("type") + if _type == "table": + t_name = table_info.get("table_name") + d_source = table_info.get("data_source") + print(f"name {t_name},source {d_source}") + if t_name and d_source: + sources.add((t_name, d_source)) + + elif _type == "query": + nested = table_info.get("query_name") + print(f"nested: {nested}") + if nested: + nested_queries.append(nested) + + table_list = [] + + if sources: + Table = frappe.qb.DocType("Insights Table v3") + # Logic: (Table='A' & Source='1') | (Table='B' & Source='2') + conditions = None + for table_name, data_source in sources: + rule = (Table.table == table_name) & (Table.data_source == data_source) + if conditions is None: + conditions = rule + else: + conditions |= rule + + if conditions: + table_list = frappe.qb.from_(Table).select(Table.name).where(conditions).run(pluck=True) + + if nested_queries: + # we call the function again with new new names + # and results are merged into our final list + nested_tables = self._get_tables_from_queries(nested_queries, visited) + table_list.extend(nested_tables) + + result = list(set(table_list)) + return result + def has_doc_permission(doc, ptype, user): return InsightsPermissions(user).has_doc_permission(doc, ptype)