Sisense Community logo
    • Community Feedback
    • Chapters
    • Events
    • Forums
      • Help and How To
      • Product Feedback Forum
      • Strategy & Use Cases
    • Blogs
    • KB Docs
      • KB Docs
      • Add-Ons & Plug-Ins
      • APIs
      • Best Practices
      • Blox
      • CDT
      • Cloud Managed Service
      • Data Models
      • Data Sources
      • Embedding Analytics
      • How-Tos & FAQs
      • Onboarding
      • PySisense
      • Security
      • Sisense Administration
      • Sisense Intelligence & AI
      • Troubleshooting
      • Widget & Dashboard Scripts
    • Support
    • Learning
      • Sisense Academy: Free Courses and Certifications
      • Official Developer Documentation
      • Official Product Documentation
      • Official Sisense Youtube Channel
      • Sisense Compose SDK Playground
    • Use Case Gallery
    All PostsDiscussionsBlogsIdeasQuestions
    Leaderboards
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   
    Discussions
    • TagsChevronRightIcon
    Use Cases
    • Blog banner
      • APIsChevronRightIcon

      UserReplaceTool - Automating Dashboard Ownership Transfers - Useful for Deleting User Accounts

                                                                                                       

      Automating Dashboard Ownership Transfer in Sisense with UserReplaceTool Managing and deleting user accounts in Sisense can create manual processes when users leave an organization or change roles. A frequent issue is the reassignment of dashboard ownership to prevent losing Sisense dashboards when a given user account is deleted, as deleting a Sisense user will delete all dashboards owned by that user. The UserReplaceTool addresses this task by automating the transfer of dashboard ownership of all dashboards owned by a given user, ensuring continuity and data integrity. Overview UserReplaceTool is a Python-based, API-based Tool solution designed to seamlessly transfer the ownership of dashboards and data models from one user to another in Sisense. This tool simplifies and automates this process, allowing organizations to reassign dashboard ownership without manual processes or the risk of losing dashboards and widgets. All components are accomplished by using Sisense API endpoint requests. Key Features Automated Dashboard Transfer : Reassigns ownership of all dashboards from the current user to a designated replacement user. Data Model Sharing : Ensures that all data models accessible and editable by the previous user are shared with the replacement user. Batch User Processing : Capable of handling multiple user transfers to a replacement user in a single operation, enhancing efficiency. Complete Logging:  All dashboards and datasource transferred are logged both in the console and in a separate log file. Setup Instructions 1. Setting Up the Environment To run the tool within a Python virtual environment , follow these steps: Activate the Python Virtual Environment : source /venv/bin/activate Create a Virtual Environment (if not already present): python3 -m venv .venv Install Dependencies : pip3 install -r requirements.txt For manual installation, including without using a Python virtual environment: pip3 install urllib3 jsonpath_ng pyyaml requests colorama 2. Configuration Edit the settings.yaml file to configure the tool. Key parameters include: Sisense Domain and Port : Specify the URL and port of your Sisense server, which is used for making API requests. API Bearer Token : Provide an admin-level bearer token for API authentication. See the  Sisense API Bearer Token Documentation for instructions on generating and using Bearer Tokens. Users to Replace : List the user IDs to be replaced. User IDs can be retrieved via the Users API or using the console command prism .user._id. Replacement User : Specify the user ID of the new replacement owner of all dashboards. This should typically be an admin level user. Data Model Sharing : Enable or disable the sharing of data models with the Replacement user (True or False), usually True. Dashboard Ownership Transfer : Enable or disable the transfer of dashboard ownership (True or False), usually True. Unlike Dashboards, if a user is deleted, the datasources themselves are not deleted from the server, ownership is automatically transferred to the main System Admin of the Sisense server.  3. Running the Tool Run the tool with the following command:   python3 replaceUser.py Practical Considerations Admin and Network Access : Ensure you have admin-level API access to the Sisense instance. Python Environment : Python3 and pip should be installed on the machine running the tool. All dependencies can be installed using Pip, and a Python virtual environment can be used. Once a Python Virtual Environment folder is set up it can be shared with the Tool and run directly on all systems using the same OS, but it is not cross OS compatible. By automating the transfer of dashboard ownership, UserReplaceTool provides a reliable and efficient solution for managing user transitions in Sisense. This ensures that datasources and dashboards remain accessible and under the control of the appropriate users, maintaining the continuity of Sisense resources. For further customization and configuration details, refer to the attached full Python Tool, which includes a README file and modify the "settings.yaml" file as necessary.    

      Jeremy Friedel
      Jeremy FriedelPosted 2 years ago • Last reply 3 days ago
      4
               
    • Blog banner
      • PySisenseChevronRightIcon

      Modifying Sisense Widgets With PySisense

                                                                                                                                       

      Modifying Sisense Widgets With PySisense Overview A chart widget's metadata, including the dimension driving its category axis and the measures driving its values, lives inside the widget's own JSON definition rather than on a separate settings screen. Changing one, through the widget's Edit panel, can be done quickly. Mass modifying the same field across many widgets, or repeating the same change every time a dashboard is rebuilt from a template, is not something the Sisense UI is built to do in bulk. This article describes swap_widget_dimension.py , a Python command-line tool that reads a Sisense chart widget, replaces the field used by one item on a named panel, and writes the result back using the pySisense library. By default it targets the categories panel, a bar chart's category axis, but the same get_widget_by_id and update_widget calls apply to any panel in the widget's metadata.panels structure, including values , so the tool also works for swapping a measure. The worked example throughout is a bar chart with two versions on a dashboard that differ only in their measure: one totals Revenue, the other totals Cost. The script changes only the field of one panel item on one widget per run. It is meant primarily as an example, and can be adapted to cover other widget attributes as needed. The full source is available on GitHub in this repository . About PySisense PySisense is a Python SDK for the Sisense REST API, installed via pip . It provides wrappers around common Sisense operations and handles authentication, session management, and request errors internally. This script uses three methods from pySisense's Dashboard class: get_widget_by_id , which retrieves a widget's full current definition; update_widget , which writes a modified definition back and strips the fields Sisense manages internally ( oid , owner , created , and so on) before sending the request; and, through Dashboard 's access_mgmt attribute, get_my_user , which identifies the Sisense user the API token belongs to. Note: The current release of PySisense on PyPI does not yet include get_widget_by_id , update_widget , or find_widgets_by_type . This functionality is planned for the next PySisense release. The development branch of the PySisense repository already contains it and can be installed directly from there: pip install "git+https://github.com/sisense/pysisense.git@dev" How a chart widget stores its fields A Sisense chart widget's configuration lives under metadata.panels , a list of panel objects. For a bar chart, the panel named categories holds the dimension shown on the category axis, and the panel named values holds the measures. Each panel has an items list, and each item carries a jaql object identifying one field, with table , column , and dim keys ( dim in the form [Table.Column] ), plus a title and other fields, such as datatype or agg , that vary by field type. Panel names and item order can differ by widget type and by how a chart was originally built, so it's worth confirming the structure on the specific widget being changed rather than assuming it. Running the script with --show prints a widget's current panels for exactly this purpose, before anything is changed: python swap_widget_dimension.py --show Prerequisites Python 3.10 or later pySisense (for now, currently the dev branch, this will be merged into the main branch shortly) and PyYAML Network access to the Sisense instance A Sisense API token: Admin > REST API > v1.0 > GET /authentication/tokens/api Only a dashboard's owner can write to its widgets. If the API token's user does not own the dashboard being changed, use a token belonging to the dashboard's owner, or transfer ownership first with pySisense's change_dashboard_owner . Setup pip install "git+https://github.com/sisense/pysisense.git@dev" pyyaml Create a config.yaml with the Sisense instance details, the widget to change, and the field to switch to: sisense: domain: https://your-instance.sisense.com token: YOUR_API_TOKEN_HERE is_ssl: true widget: dashboard_id: YOUR_DASHBOARD_ID widget_id: YOUR_WIDGET_ID dimension: table: Commerce column: Region title: Region Run with --show first to confirm the widget has the panel expected, then continue with the sequence in Usage. Configuration reference sisense.domain , sisense.token , sisense.is_ssl : connection details for the Sisense instance. widget.dashboard_id , widget.widget_id : the widget to change. Both can be overridden for a single run with --dashboard-id and --widget-id without editing config.yaml . widget.admin_access : left unset by default, which auto-detects the right setting (see Troubleshooting below). Set to true or false , or pass --admin-access or --no-admin-access , only to skip that auto-detection. dimension.panel : the metadata.panels entry to change. categories for a bar chart's category axis, values for its measures. Defaults to categories if unset. Can be overridden with --panel . dimension.table , dimension.column , dimension.title : the new field to apply. table and column identify it; title is the label shown on the widget and defaults to column when omitted. All three can be overridden with --table , --column , and --title . Usage Inspect the widget's current panels before changing anything: python swap_widget_dimension.py --show Preview the change without writing it: python swap_widget_dimension.py Apply the change: python swap_widget_dimension.py --apply If the result is not correct, revert to the widget's prior state: python swap_widget_dimension.py --undo Troubleshooting Whether adminAccess is needed depends on ownership, not the Sisense version. get_widget_by_id can fail with a schema validation error mentioning adminAccess , on Sisense instances that reject the parameter outright, or with a permission error, on instances that require it for a widget the API token's user doesn't own. Rather than have the caller guess which applies, the script tries without admin access first, and only retries with admin access if that fails. It also prints a line identifying the token's user and whether that user owns the widget, so the reason either attempt succeeded is visible. --admin-access and --no-admin-access are still available to force one path and skip the auto-detection. Worked example: swapping a measure from Revenue to Cost A dashboard can have two versions of the same bar chart widget that differ only in their values panel, one totaling Commerce.Revenue , the other Commerce.Cost . Pointing the script at the Revenue widget and targeting the values panel swaps it to Cost, keeping its sum aggregation intact: python swap_widget_dimension.py \ --dashboard-id YOUR_DASHBOARD_ID --widget-id YOUR_WIDGET_ID \ --panel values --table Commerce --column Cost --title "Total Cost" \ --apply Running --show against the widget beforehand returns: { "table": "Commerce", "column": "Revenue", "dim": "[Commerce.Revenue]", "datatype": "numeric", "columnTitle": "Revenue", "tableTitle": "Commerce", "agg": "sum", "title": "Total Revenue" } and after --apply : { "table": "Commerce", "column": "Cost", "dim": "[Commerce.Cost]", "datatype": "numeric", "columnTitle": "Cost", "tableTitle": "Commerce", "agg": "sum", "title": "Total Cost" } table , column , dim , columnTitle , tableTitle , and title all changed to Cost; datatype and the sum aggregation in agg carried over unchanged, exactly as build_new_jaql is designed to do. Common use cases Reusing one chart across dashboard variants A bar chart that should show a different measure, or a different category dimension, on each of several dashboards can be built once and adjusted per dashboard with a single command instead of being rebuilt in the widget editor each time. Correcting a field after templating Dashboards built from a template sometimes carry the template's placeholder field. Rather than reopening every affected widget by hand, each one can be pointed at the correct field directly. Working with more than one item on a panel A widget with more than one item on the panel being changed, such as a grouped bar chart with two category dimensions, needs --match-column to identify which one to replace: python swap_widget_dimension.py --apply --match-column Category Full option reference usage: swap_widget_dimension.py [-h] [--config FILE] [--dashboard-id ID] [--widget-id ID] [--panel PANEL] [--table TABLE] [--column COLUMN] [--title TITLE] [--match-column COLUMN] [--show] [--apply] [--undo] [--backup-file FILE] [--admin-access | --no-admin-access] Swap the field used by one item on a Sisense chart widget panel. options: -h, --help show this help message and exit --config FILE Path to config.yaml. --dashboard-id ID Overrides widget.dashboard_id from config.yaml. --widget-id ID Overrides widget.widget_id from config.yaml. --panel PANEL Name of the metadata.panels entry to change, for example categories (a bar chart's category axis) or values (its measures). Overrides dimension.panel from config.yaml. Defaults to 'categories' if neither is set. --table TABLE Overrides dimension.table from config.yaml. --column COLUMN Overrides dimension.column from config.yaml. --title TITLE Overrides dimension.title from config.yaml. --match-column COLUMN Column name of the item to replace within --panel, if it has more than one item. Defaults to the first item. --show Print the widget's current metadata.panels and exit. Use this to confirm panel names and item order before swapping anything. --apply Write the change to Sisense. Without this flag, only a preview is printed. --undo Restore the widget to the state recorded in --backup- file, then exit. --backup-file FILE Where the widget's prior state is saved before a change, and read back from during --undo. --admin-access Always fetch with adminAccess=true, skipping auto- detection. Needed when the API token's user does not own the widget. --no-admin-access Always fetch without adminAccess=true, skipping auto- detection. Needed on Sisense instances that reject adminAccess as an unrecognized query parameter. Use case Using swap_widget_dimension.py instead of manual widget edits offers several advantages: Applies a field change to a widget directly through its JSON definition, without opening the widget editor Works on either a chart's category dimension or its measures, through the same --panel option Provides a way to confirm a widget's panel structure with --show before any change is made Auto-detects whether admin access is needed, rather than requiring the caller to know their token's relationship to the widget in advance Provides a backup of the widget's prior state before every applied change, with --undo to restore it Demonstrates how pySisense's get_widget_by_id and update_widget can be used together to read, modify, and write back any field in a widget's definition, not only its dimension Summary swap_widget_dimension.py gives Sisense administrators and developers a way to change the field used by a chart widget's category axis or its measures from the command line, with a preview step, a way to inspect the widget's actual structure first, auto-detection of whether admin access is needed, and a backup that makes any applied change reversible. The same get_widget_by_id and update_widget pattern used here, fetch the widget, modify the field that needs to change, write it back, applies to other widget fields beyond the ones shown in this example. """swap_widget_dimension.py: swap the field used by one item on a Sisense chart widget panel. Reads a widget with ``Dashboard.get_widget_by_id()``, replaces the field used by one item in a named ``metadata.panels`` entry, such as ``categories`` (a bar chart's category axis) or ``values`` (its measures), and writes the result back with ``Dashboard.update_widget()``. "Dimension" is used loosely here, the way most readers of this script will mean it: the field dropped into that panel, whether it is technically a dimension or a measure. This is a worked example of pySisense's widget read and write functions, not a general purpose widget editor: it changes one field on one panel item on one widget per run, and does not touch anything else on the widget. Note: get_widget_by_id, update_widget, and find_widgets_by_type are not yet in the pySisense release on PyPI. Install from the dev branch to use this script: pip install "git+https://github.com/sisense/pysisense.git@dev" Usage ----- python swap_widget_dimension.py --show # print the widget's panels, change nothing python swap_widget_dimension.py # preview the swap, change nothing python swap_widget_dimension.py --apply # write the change to Sisense python swap_widget_dimension.py --undo # restore from the backup file python swap_widget_dimension.py --panel values --apply # swap a measure instead of the category axis Whether a widget fetch needs adminAccess=true depends on whether the API token's own user already owns the widget. Rather than requiring the caller to know that in advance, fetch_widget() below tries without admin access first and only escalates if that fails. """ from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Any import yaml from pysisense import Dashboard, SisenseClient SCRIPT_DIR = Path(__file__).parent DEFAULT_CONFIG_PATH = SCRIPT_DIR / "config.yaml" DEFAULT_BACKUP_PATH = SCRIPT_DIR / "widget_backup.json" # The metadata.panels entry this script targets when --panel and # dimension.panel are both left unset: a bar chart's category axis. DEFAULT_PANEL_NAME = "categories" def load_config(path: Path) -> dict[str, Any]: """Load and parse the YAML configuration file. Parameters ---------- path : Path Location of the config.yaml file to read. Returns ------- dict[str, Any] The parsed configuration. Empty dict if the file is empty. Raises ------ SystemExit If no file exists at ``path``. """ if not path.exists(): sys.exit( f"Config file not found: {path}\n" "Copy config.example.yaml to config.yaml and fill in the instance URL, " "token, and widget details." ) with path.open() as config_file: return yaml.safe_load(config_file) or {} def build_client(config: dict[str, Any]) -> SisenseClient: """Construct a SisenseClient from the ``sisense`` section of config.yaml. Parameters ---------- config : dict[str, Any] The full parsed configuration, as returned by :func:`load_config`. Returns ------- SisenseClient A client ready to use with pySisense's Dashboard class. Raises ------ SystemExit If ``sisense.domain`` or ``sisense.token`` is missing. """ sisense_config = config.get("sisense", {}) domain = sisense_config.get("domain", "") token = sisense_config.get("token", "") if not domain or not token: sys.exit("config.yaml must include sisense.domain and sisense.token.") return SisenseClient(domain=domain, token=token, is_ssl=sisense_config.get("is_ssl", True)) def describe_current_user(dashboard: Dashboard) -> dict[str, Any] | None: """Look up the Sisense user the API token belongs to. Used only to print a diagnostic line explaining whether the token's own user already owns the widget being changed, which is what actually determines whether admin access is needed (see :func:`fetch_widget`). A failed lookup here is not fatal to the rest of the script. Parameters ---------- dashboard : Dashboard A pySisense Dashboard instance, whose ``access_mgmt`` attribute exposes ``get_my_user()``. Returns ------- dict[str, Any] | None The logged-in user object (``GET /api/users/loggedin``), or ``None`` if the lookup failed for any reason. """ try: user = dashboard.access_mgmt.get_my_user() except Exception: return None if isinstance(user, dict) and "error" not in user: return user return None def fetch_widget( dashboard: Dashboard, dashboard_id: str, widget_id: str, *, admin_access: bool | None, ) -> tuple[dict[str, Any], bool]: """Fetch a widget, auto detecting whether admin access is required. Whether a fetch needs ``adminAccess=true`` depends on whether the API token's own user already owns the widget, not on the Sisense version. On top of that, some Sisense versions reject ``adminAccess`` outright as an unrecognized query parameter, which looks identical from here to "not the owner". Rather than have the caller guess, this tries the cheaper, no-admin-access request first, and only escalates if that fails. Parameters ---------- dashboard : Dashboard A pySisense Dashboard instance. dashboard_id : str The ``oid`` of the dashboard that contains the widget. widget_id : str The ``oid`` of the widget to fetch. admin_access : bool | None ``True`` or ``False`` forces that mode and skips auto-detection. ``None`` tries without admin access first, then retries with admin access only if the first attempt fails. Returns ------- tuple[dict[str, Any], bool] The fetched widget, and whether admin access was actually used to retrieve it. Raises ------ SystemExit If the widget cannot be fetched in the requested mode(s). """ if admin_access is not None: widget = dashboard.get_widget_by_id(dashboard_id, widget_id, admin_access=admin_access) if "error" in widget: sys.exit(f"Could not fetch widget: {widget['error']}") return widget, admin_access widget = dashboard.get_widget_by_id(dashboard_id, widget_id, admin_access=False) if "error" not in widget: return widget, False error_without_admin_access = widget["error"] widget = dashboard.get_widget_by_id(dashboard_id, widget_id, admin_access=True) if "error" not in widget: return widget, True sys.exit( "Could not fetch widget, with or without admin access.\n" f" Without admin access: {error_without_admin_access}\n" f" With admin access: {widget['error']}" ) def print_fetch_diagnostics(dashboard: Dashboard, widget: dict[str, Any], admin_access_used: bool) -> None: """Print a line explaining whether the token's user owns this widget. Purely informational: makes the reason admin access was, or was not, needed for this particular widget visible, instead of leaving the ``True``/``False`` in ``admin_access_used`` unexplained. Printed to stderr, not stdout, so it never lands in ``--show``'s JSON output and can be piped straight into something like ``jq``. Parameters ---------- dashboard : Dashboard A pySisense Dashboard instance, used to look up the current user. widget : dict[str, Any] The widget returned by :func:`fetch_widget`. admin_access_used : bool Whether the fetch that retrieved ``widget`` used admin access. Returns ------- None """ access_note = "with admin access" if admin_access_used else "without admin access" current_user = describe_current_user(dashboard) if current_user is None: print(f"Fetched widget {access_note} (could not look up the current user for comparison).", file=sys.stderr) return identity = current_user.get("email") or current_user.get("userName") or current_user.get("_id") owns_widget = current_user.get("_id") == widget.get("owner") ownership_note = "owns this widget" if owns_widget else "does not own this widget" print(f"Fetched widget {access_note}. Authenticated as {identity!r}, who {ownership_note}.", file=sys.stderr) def find_panel_item( widget: dict[str, Any], panel_name: str, match_column: str | None, ) -> tuple[list[dict[str, Any]], int]: """Locate the panel item to change. Parameters ---------- widget : dict[str, Any] The full widget object, as returned by ``get_widget_by_id``. panel_name : str The ``metadata.panels`` entry to search, for example ``"categories"`` (a bar chart's category axis) or ``"values"`` (its measures). match_column : str | None If given, selects the item whose current ``jaql.column`` equals it. If ``None``, the first item in the panel is used, which is correct whenever that panel only has one item, such as a simple bar chart's single category dimension or single measure. Returns ------- tuple[list[dict[str, Any]], int] The panel's ``items`` list, and the index within it of the item to change. Raises ------ SystemExit If the widget has no panel named ``panel_name``, the panel has no items, or ``match_column`` does not match any item in it. """ panels = widget.get("metadata", {}).get("panels", []) panel = next((candidate for candidate in panels if candidate.get("name") == panel_name), None) if panel is None or not panel.get("items"): sys.exit(f"No {panel_name!r} panel with items found on this widget. Run --show to inspect its structure.") items = panel["items"] if match_column is None: return items, 0 for index, item in enumerate(items): if item.get("jaql", {}).get("column") == match_column: return items, index sys.exit(f"No item with column {match_column!r} found in the {panel_name!r} panel. Run --show to see the current items.") def build_new_jaql(existing_jaql: dict[str, Any], table: str, column: str, title: str | None) -> dict[str, Any]: """Return a copy of ``existing_jaql`` with its field swapped. ``table``, ``column``, and ``dim`` always change. ``title`` changes to ``title`` or, if that is ``None``, to ``column``. ``columnTitle`` and ``tableTitle`` (Sisense's own display-label mirrors of ``column`` and ``table``, confirmed by inspecting a live widget) are updated to match whenever the existing jaql already had them, so nothing about the field still points at the old one under a different key. ``agg`` and every other key already present (``datatype``, and so on) are carried over unchanged. Preserving ``agg`` in particular is what makes this safe to use on a ``values`` panel item, not only a ``categories`` one: swapping Revenue for Cost keeps the sum aggregation the widget already had, instead of resetting it. Parameters ---------- existing_jaql : dict[str, Any] The jaql object currently on the panel item being replaced. table : str The table of the new field. column : str The column of the new field. title : str | None The label to show on the widget for the new field. Defaults to ``column`` when ``None``. Returns ------- dict[str, Any] A new jaql object with the field swapped. """ new_jaql = dict(existing_jaql) new_jaql["table"] = table new_jaql["column"] = column new_jaql["dim"] = f"[{table}.{column}]" new_jaql["title"] = title or column if "columnTitle" in new_jaql: new_jaql["columnTitle"] = column if "tableTitle" in new_jaql: new_jaql["tableTitle"] = table return new_jaql def write_widget(dashboard: Dashboard, dashboard_id: str, widget_id: str, payload: dict[str, Any]) -> None: """Write ``payload`` back to Sisense via ``Dashboard.update_widget()``. Parameters ---------- dashboard : Dashboard A pySisense Dashboard instance. dashboard_id : str The ``oid`` of the dashboard that contains the widget. widget_id : str The ``oid`` of the widget being written. payload : dict[str, Any] The full widget object to write back. Returns ------- None Raises ------ SystemExit If the API reports a write failure. """ result = dashboard.update_widget(dashboard_id, widget_id, payload) if "error" in result: sys.exit(f"Update failed: {result['error']}") def parse_args() -> argparse.Namespace: """Define and parse this script's command-line arguments. Returns ------- argparse.Namespace The parsed arguments. """ parser = argparse.ArgumentParser(description="Swap the field used by one item on a Sisense chart widget panel.") parser.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), metavar="FILE", help="Path to config.yaml.") parser.add_argument("--dashboard-id", metavar="ID", help="Overrides widget.dashboard_id from config.yaml.") parser.add_argument("--widget-id", metavar="ID", help="Overrides widget.widget_id from config.yaml.") parser.add_argument( "--panel", help="Name of the metadata.panels entry to change, for example categories (a bar " "chart's category axis) or values (its measures). Overrides dimension.panel from " f"config.yaml. Defaults to {DEFAULT_PANEL_NAME!r} if neither is set.", ) parser.add_argument("--table", help="Overrides dimension.table from config.yaml.") parser.add_argument("--column", help="Overrides dimension.column from config.yaml.") parser.add_argument("--title", help="Overrides dimension.title from config.yaml.") parser.add_argument( "--match-column", metavar="COLUMN", help="Column name of the item to replace within --panel, if it has more than one item. " "Defaults to the first item.", ) parser.add_argument( "--show", action="store_true", help="Print the widget's current metadata.panels and exit. Use this to confirm panel " "names and item order before swapping anything.", ) parser.add_argument( "--apply", action="store_true", help="Write the change to Sisense. Without this flag, only a preview is printed.", ) parser.add_argument( "--undo", action="store_true", help="Restore the widget to the state recorded in --backup-file, then exit.", ) parser.add_argument( "--backup-file", default=str(DEFAULT_BACKUP_PATH), metavar="FILE", help="Where the widget's prior state is saved before a change, and read back from during --undo.", ) admin_access_group = parser.add_mutually_exclusive_group() admin_access_group.add_argument( "--admin-access", dest="admin_access", action="store_true", default=None, help="Always fetch with adminAccess=true, skipping auto-detection. Needed when the API " "token's user does not own the widget.", ) admin_access_group.add_argument( "--no-admin-access", dest="admin_access", action="store_false", help="Always fetch without adminAccess=true, skipping auto-detection. Needed on Sisense " "instances that reject adminAccess as an unrecognized query parameter.", ) return parser.parse_args() def main() -> None: """Entry point: parse arguments, then fetch, inspect, swap, or restore a widget's dimension.""" args = parse_args() config = load_config(Path(args.config)) client = build_client(config) dashboard = Dashboard(api_client=client) widget_config = config.get("widget", {}) dashboard_id = args.dashboard_id or widget_config.get("dashboard_id") widget_id = args.widget_id or widget_config.get("widget_id") if not dashboard_id or not widget_id: sys.exit( "dashboard_id and widget_id are required, either in config.yaml under 'widget' " "or via --dashboard-id/--widget-id." ) admin_access = args.admin_access if args.admin_access is not None else widget_config.get("admin_access") if args.undo: backup_path = Path(args.backup_file) if not backup_path.exists(): sys.exit(f"No backup file found at {backup_path}. Nothing to undo.") original_widget = json.loads(backup_path.read_text()) write_widget(dashboard, dashboard_id, widget_id, original_widget) print(f"Widget {widget_id} restored from {backup_path}.") return widget, admin_access_used = fetch_widget(dashboard, dashboard_id, widget_id, admin_access=admin_access) print_fetch_diagnostics(dashboard, widget, admin_access_used) if args.show: print(json.dumps(widget.get("metadata", {}).get("panels", []), indent=2)) return dimension_config = config.get("dimension", {}) panel_name = args.panel or dimension_config.get("panel") or DEFAULT_PANEL_NAME table = args.table or dimension_config.get("table") column = args.column or dimension_config.get("column") title = args.title or dimension_config.get("title") if not table or not column: sys.exit("table and column are required, either in config.yaml under 'dimension' or via --table/--column.") items, index = find_panel_item(widget, panel_name, args.match_column) old_jaql = items[index].get("jaql", {}) new_jaql = build_new_jaql(old_jaql, table, column, title) print(f"\nWidget: {widget.get('title') or widget_id!r} ({widget.get('type')})") print(f" {'Panel:':<15}{panel_name!r}") print(f" {'Current field:':<15}{old_jaql.get('table')}.{old_jaql.get('column')} (title: {old_jaql.get('title')!r})") print(f" {'New field:':<15}{table}.{column} (title: {new_jaql['title']!r})") if not args.apply: print("\n[PREVIEW] No changes made. Re-run with --apply to write this change to Sisense.") return backup_path = Path(args.backup_file) backup_path.write_text(json.dumps(widget, indent=2)) print(f"\nBacked up current widget state to {backup_path}.") items[index]["jaql"] = new_jaql write_widget(dashboard, dashboard_id, widget_id, widget) print(f"Widget {widget_id} updated. To revert: python {Path(__file__).name} --undo --backup-file {backup_path}") if __name__ == "__main__": main()

      Before and After Change Via Tool
      Disclaimer: This post outlines a potential custom workaround for a specific use case or provides instructions regarding a specific task. The solution may not work in all scenarios or Sisense versions, so we strongly recommend testing it in your environment before deployment. If you need further assistance with this, please let us know.

      Jeremy Friedel
      Jeremy FriedelPosted 1 month ago
      0
               
    • Discussion
      Ido Darnell
      • Sisense Partners
               
      Ido Darnell
      Pixel-perfect design and reporting for Sisense, a review of available options
                                                                                                                                               

      If you manage Sisense, you've had some version of this conversation: someone asks for a report that actually looks like a finished document, not a dashboard screenshot with the edges cut off. There are more ways to solve this than most admins realize, and each one solves a different piece of the problem. A quick rundown: Native PDF export - zero setup, but the layout is whatever the dashboard happens to produce. No control over page breaks, no way to add narrative text, and pivot tables cut off at the edge of the screen. Sisense Report Manager - solid scheduling and delivery, but it's still exporting the dashboard as-is. No design layer, no multi-dashboard composition. Jasper Reports - genuinely pixel-perfect for table and pivot widgets specifically, if you have developer resource to build and maintain JRXML templates. Doesn't touch charts or KPI widgets. SAP Crystal Reports via ODBC - full design control, but it pulls your data out of Sisense entirely to do it, which is a real consideration if data residency matters to you. Where this leaves most teams: something that handles scheduling, or something that handles pixel-perfect tabular data, but not both, and rarely anything that lets you design a report as its own document rather than a dashboard export. That's the gap QBeeQ Report Studio is built for: a canvas inside Sisense where you design the report as a report - pulling from multiple dashboards if you need to, with full control over layout, brand, and every widget on the page. Report Studio Deliver , our scheduled distribution layer, is coming in beta this month. For the full breakdown with a side-by-side comparison table, here's the complete write-up .

      1 month ago
      0
               
    • Blog banner
      • Widget & Dashboard ScriptsChevronRightIcon

      Setting Maximum Panel Items on Widget Panels via Scripting

                                                                                       

      Setting Maximum Items on Widget Panels via Script Sisense widgets set max panel item per type (values, rows, etc) limits that control how many dimensions, measures, and filters can be added to each panel. When these limits are reached, the Add Panel button is hidden, preventing users from adding additional fields. A widget script that sets the maxitems property on each panel is commonly used to raise (or occasionally lower) this limit. This very commonly used script may be experiencing issues on some new releases of Sisense (L2026.2.2-c). Dashboard designers and developers who have this script in place and are not experiencing any issues may leave the script unchanged. Dashboard designers who are experiencing problems can replace the script with the version shared below. For use cases that specifically use this script for pivot widgets and want it applied automatically across many widgets or configured per user group, the PivotMaxPanelItems plugin is a more scalable alternative. See Extending Pivot Widget Panel Limits in Sisense Using User Groups for details. Script That May Not Work The following script is commonly used as a widget script to raise the panel item limit: widget.manifest.data.panels.forEach(function(p) { p.metadata.maxitems = 40; // replace with the value needed }); This script accesses the panel metadata maxitems parameter directly through widget.manifest.data.panels . Based on current behavior, this path appears to be broken in some environments on specific Sisense versions.

      Without the script applied, if a panel type includes too many items, the Add Panel Button is hidden. Replacement Script The following script produces the same result and can be more reliable. widget.on('initialized', function() { widget.metadata.panels.forEach(function(p) { p.$$manifest.metadata.maxitems = 60; // replace with the value needed }); }); This version waits for the initialized event before accessing panel data, and references the panel's $$manifest.metadata path rather than going through widget.manifest.data . Dashboard designers and developers should update the maxitems value to match the limit needed for their use case. Note that setting a very high limit and adding a large number of fields to a widget may result in extended load times.
      With the script applied, the Add Panel Button does not disappear when below the new limit, and as many dimensions and fields as needed can be added to the widget. Applying This to More Complex Scripts and Plugins This potential fix applies to any plugin or script that sets maxitems on widget panels and uses this exact path. If a plugin or script accesses panel metadata through widget.manifest.data.panels and is functioning correctly, no changes are necessary. If it appears to no longer work, updating it to use widget.metadata.panels and reference p.$$manifest.metadata is likely to fix the issue. The scripts above are simply examples. More dynamic implementations, such as those that read current values, apply conditional logic, or use different values based on user type or user group, follow the same principle, if the older method is not working, replacing the maxitems path is a likely fix.

      Jeremy Friedel
      Jeremy FriedelPosted 2 months ago
      0
               
    • Blog banner
      • PySisenseChevronRightIcon

      Managing Sisense Plugin States with PySisense

                                                                                                       

      Managing Sisense Plugin States with PySisense Overview Enabling and disabling Sisense plugins through the admin interface becomes time consuming when maintaining consistent plugin configurations across environments, testing against a clean instance, or iterating on plugin development. This article describes plugin_manager_pysisense.py , a Python command-line tool that manages Sisense plugin states using the PySisense library and a configuration file. The script is also a working example of how PySisense can be used for Sisense automation. This tool covers the same functionality as plugin_manager.py , documented in Managing Sisense Plugin States via the REST API . The difference is that this version delegates all API request code to PySisense rather than using the requests library directly. The two scripts share the same config.yaml format and produce identical output. The script enables and disables plugins, isolates a single plugin for faster development builds, and saves and restores named snapshots of plugin state. It does not install, uninstall, or update plugins, and does not roll back plugin versions. About PySisense PySisense is introduced in PySisense: Programmable Sisense Environment Management on the Sisense Community as a Python SDK for the Sisense REST API. It provides wrappers around common Sisense operations, including plugin management, dashboard access, and user administration. It is installed via pip and does not need to be placed alongside the script. This script provides a practical example of PySisense applied to a real automation task. Using PySisense rather than writing directly against the REST API reduces the amount of code required and removes several categories of boilerplate. Authentication headers, HTTP session management, SSL configuration, pagination across large result sets, and request logging are all handled by the library. The consistent SisenseClient and module pattern also makes it straightforward to extend a script to cover other Sisense operations, such as users, dashboards, or data models, without reworking connection and authentication code. Scripts built on PySisense are shorter and easier to read than equivalent scripts built on requests directly, and the library is maintained and tested independently of the scripts that use it. Note: The current release of PySisense on PyPI does not yet include the Plugins module used in this script. This functionality is planned for the next PySisense release. The development branch of the PySisense repository already contains it and can be installed directly from there. This script uses the Plugins class from PySisense: get_all_plugins() returns the full plugin list as a list of dicts, handling the pagination in the underlying API automatically. enable_plugins(names, bulk=True) enables one or more plugins by name. When bulk=True , all changes are sent in a single PATCH request. disable_plugins(names, bulk=True) disables one or more plugins by name, with the same bulk option. A SisenseClient instance is constructed from the config values and passed to the Plugins class. All HTTP calls are then handled by PySisense. from pysisense import Plugins, SisenseClient client = SisenseClient(domain="https://your-sisense-instance.com", token="YOUR_TOKEN") plugins = Plugins(api_client=client) # List all installed plugins all_plugins = plugins.get_all_plugins() # Enable plugins by short name, full name, or folderName result = plugins.enable_plugins(["SwitchDimension", "plugin-CustomPlugin"], bulk=True) # Disable plugins result = plugins.disable_plugins(["UnneededPlugin"], bulk=True) Both enable_plugins and disable_plugins return a summary dict with changed , already_enabled or already_disabled , not_found , and errors keys. Features that require knowing the current plugin state before making changes, such as strict mode, enable-one, and dry-run previews, call get_all_plugins() once at the start of the command and compute the set of changes at the script level. Plugin name matching Plugin names in the configuration file and on the command line accept either the short form ( SwitchDimension ) or the full form ( plugin-SwitchDimension ). Matching is case-insensitive. PySisense normalizes names internally, so both forms work with enable_plugins() and disable_plugins() as well. Prerequisites Python 3.10 or later Network access to the Sisense instance (the tool runs remotely and does not need to be installed on the server) A Sisense API token: Admin > REST API > v1.0 > GET /authentication/tokens/api Setup Install the required Python packages: pip install pysisense pyyaml Create a config.yaml and fill in the Sisense instance URL and API token: sisense: url: https://your-sisense-instance.com token: YOUR_API_TOKEN_HERE Add the plugins to enable to the enable_plugins list. See the Configuration reference section for all available settings. The script is then ready to run. Configuration reference sisense: url: https://your-sisense-instance.com token: YOUR_API_TOKEN_HERE # Admin → REST API → v1.0 → GET /authentication/tokens/api bulk: true # set false to update plugins one at a time instead of a single batch request # Plugins to enable. # Accepts short names (SwitchDimension) or full names (plugin-SwitchDimension). # Matching is case-insensitive. # # Default behavior: enable listed plugins; leave every other plugin unchanged. # With --strict: also disable any currently-enabled plugin NOT in this list, # making the instance exactly match this config. enable_plugins: - examplePlugin # Plugins to explicitly disable (optional). # Only needed in the default sync (no --strict), where unlisted plugins are # left alone. Listing a plugin here ensures it stays off and documents the # intentional exclusion. # disable_plugins: # - anotherExamplePlugin enable_plugins : the list of plugins to enable. By default, plugins not on this list are left in their current state. With --strict , any currently enabled plugin not on this list will be disabled. disable_plugins : optional. Plugins to explicitly disable in the default sync without --strict . Useful for documenting intentional exclusions when strict mode is not being used. bulk : controls whether plugin state changes are sent as a single batch API request or one at a time. Set to false for individual requests, which is slower but can be useful for diagnosing issues with specific plugins. Usage Run the script with no arguments to enable plugins in enable_plugins , disable plugins in disable_plugins , and leave everything else unchanged: python plugin_manager_pysisense.py Before making any changes, the script saves a timestamped snapshot of the current plugin state. To restore the most recent snapshot: python plugin_manager_pysisense.py --restore-snapshot To preview what would change without making any API calls: python plugin_manager_pysisense.py --dry-run Common use cases Maintaining a known good plugin baseline Organizations that need to ensure consistent plugin configuration across upgrades, support sessions, or environment changes can maintain an enable_plugins list in the configuration file. Running the script applies the defined configuration to the instance. To also disable any currently enabled plugin not in the list: python plugin_manager_pysisense.py --strict Testing on a stock Sisense instance Plugin interference is a common source of issues that are difficult to reproduce. To quickly disable all plugins and test against a clean instance, clear enable_plugins in the configuration file and run with --strict . The current state is saved automatically before any changes are made, and can be restored when testing is complete: # with enable_plugins cleared in config.yaml python plugin_manager_pysisense.py --strict # restore when done python plugin_manager_pysisense.py --restore-snapshot Developing and testing a single plugin When many plugins are installed, Sisense rebuilds all of them whenever any plugin changes. Using --enable-one disables all currently enabled plugins and enables only the specified plugin, which can significantly reduce rebuild time on instances with many plugins: python plugin_manager_pysisense.py --enable-one MyPlugin Snapshots The script saves snapshots as YAML files to a snapshots/ folder alongside the script. Snapshots capture the full enabled and disabled plugin state at a point in time. Snapshots are saved automatically before any run that changes state. Named snapshots can also be saved and restored explicitly: # save the current state with a specific name python plugin_manager_pysisense.py --save-snapshot before-upgrade # list all saved snapshots python plugin_manager_pysisense.py --list-snapshots # restore a specific snapshot python plugin_manager_pysisense.py --restore-snapshot before-upgrade Including --log-version when saving a snapshot records the version of each plugin at that point in time. Snapshots saved by plugin_manager_pysisense.py and plugin_manager.py are compatible. Both scripts read from the same snapshots/ directory, and each handles the other's snapshot format. Full option reference usage: plugin_manager_pysisense.py [-h] [--config FILE] [--dry-run] [--no-bulk] [--skip-snapshot] [--log-version] [--strict] [--enable-only | --disable-only] [--enable-one PLUGIN | --save-snapshot [NAME] | --list-snapshots | --restore-snapshot [NAME]] Manage Sisense plugin states via PySisense. options: -h, --help show this help message and exit --config FILE Path to config.yaml (default: config.yaml next to this script) --dry-run Preview what would change; make no API calls and save no snapshot --no-bulk Send one API call per plugin instead of a single batch PATCH. Slower but easier to diagnose partial failures. Overrides sisense.bulk in config. --skip-snapshot Do not automatically save a snapshot before this run --log-version Record each plugin's version field (from the API response) in any snapshot saved during this run --strict Also disable every currently enabled plugin that is not listed in enable_plugins. Without this flag, only plugins in disable_plugins are disabled; everything else is left alone. Always active for --restore-snapshot. --enable-only Run only the enable step; skip all disables --disable-only Run only the disable step; skip all enables --enable-one PLUGIN Enable exactly one plugin by name and disable all currently enabled others. Accepts the short or full plugin name. --save-snapshot [NAME] Save the current plugin state as a named snapshot. Omit NAME for an auto generated timestamp filename. Combine with --log-version to also record versions. --list-snapshots List all saved snapshots with creation date and enabled plugin count --restore-snapshot [NAME] Restore plugin state to exactly match a saved snapshot. Omit NAME to use the most recently created snapshot. Always strict (disables plugins not in snapshot) unless --enable-only. Plugin names accept the short form (SwitchDimension) or the full form (plugin-SwitchDimension) in both CLI args and config.yaml. Default behavior: enable plugins in enable_plugins, disable plugins in disable_plugins, leave everything else unchanged. Use --strict to also disable any enabled plugin not in enable_plugins. A snapshot is saved automatically before each run that modifies state. Use --skip-snapshot to suppress this. Use Case Managing plugins via PySisense and a configuration file offers several advantages over manual administration: Applies plugin active list consistently and repeatably without manual steps Provides automatic rollback through snapshots saved before each change Supports quickly switching between plugin states for testing or development Reduces the time required to restore a known working plugin list after an issue is resolved Demonstrates how PySisense can be used to build automation scripts for Sisense instances Summary With this tool in place, administrators and developers can manage Sisense plugin states quickly and reliably from the command line. The configuration file serves as a single source of truth for the intended plugin set, and the snapshot system ensures that any change can be reversed. The script also serves as a concrete example of how PySisense can be used in automation scripts: constructing a SisenseClient from config values, passing it to a feature class, and using the returned result dicts to drive output and error handling. The code is not compressed or obfuscated and can be modified as needed or used as a reference for similar tools. j""" plugin_manager_pysisense.py — Manage Sisense plugin states via pySisense. Same feature set as plugin_manager.py but delegates all API calls to the pySisense library instead of calling the Sisense REST API directly. Configuration is read from config.yaml. This script is also a working example of how pySisense can be used for Sisense automation. Install pySisense with: pip install pysisense Overview Manages Sisense plugin states via pySisense. Treats plugin configuration as code: define which plugins should be enabled in config.yaml, run the script, and the instance matches, quickly, from anywhere with network access. Before applying any changes, the current plugin state is automatically captured in a timestamped snapshot. If a change needs to be reverted, a single --restore-snapshot command rolls the instance back to exactly how it was. By default only the plugins explicitly listed in config.yaml are enabled or disabled, everything else is left as is. Use --strict to also disable any enabled plugin not in the list, making the instance enabled plugin list exactly match the config. Common use cases Maintain a known good plugin baseline Keep enable_plugins in config.yaml up to date. Run the script after upgrades, support sessions, or any time the instance may have drifted from the expected state. Test against a stock (no plugins) Sisense instance Clear enable_plugins in config.yaml, run --strict to disable everything, test, then restore: python plugin_manager_pysisense.py --strict python plugin_manager_pysisense.py --restore-snapshot Develop and iterate on a single plugin quickly python plugin_manager_pysisense.py --enable-one MyPlugin Roll back any change instantly python plugin_manager_pysisense.py --list-snapshots python plugin_manager_pysisense.py --restore-snapshot before-upgrade Usage # Enable listed plugins, disable listed disable_plugins, leave rest unchanged python plugin_manager_pysisense.py # Strict: also disables any enabled plugin not in enable_plugins python plugin_manager_pysisense.py --strict # Preview what would change (no API calls, no auto snapshot) python plugin_manager_pysisense.py --dry-run python plugin_manager_pysisense.py --strict --dry-run # Run only one half of the sync python plugin_manager_pysisense.py --enable-only # enable listed; skip all disables python plugin_manager_pysisense.py --disable-only # disable listed; skip all enables # Enable exactly one plugin and disable every other currently enabled plugin python plugin_manager_pysisense.py --enable-one SwitchDimension # Snapshots python plugin_manager_pysisense.py --save-snapshot [NAME] python plugin_manager_pysisense.py --save-snapshot [NAME] --log-version python plugin_manager_pysisense.py --list-snapshots python plugin_manager_pysisense.py --restore-snapshot [NAME] # omit NAME for most recent # Other flags (combinable with any of the above) --no-bulk one API call per plugin instead of a single batch request --skip-snapshot do not save an auto-snapshot before this run --log-version record each plugin's version in any snapshot saved this run --config FILE use a config file other than the default config.yaml Plugin names All plugin names accept either the short form (SwitchDimension) or the full form (plugin-SwitchDimension), both in CLI arguments and in config.yaml. Matching is case insensitive. API token Admin → REST API → v1.0 → GET /authentication/tokens/api Setup pip install pysisense pyyaml # Python 3.10+ required cp config.yaml.example config.yaml # Edit config.yaml: set sisense.url, sisense.token, and enable_plugins list """ import sys from datetime import datetime, timezone from pathlib import Path import yaml from pysisense import Plugins, SisenseClient SCRIPT_DIR = Path(__file__).parent DEFAULT_CONFIG = SCRIPT_DIR / "config.yaml" SNAPSHOTS_DIR = SCRIPT_DIR / "snapshots" # Config def load_config(path: Path) -> dict: if not path.exists(): print(f"Config file not found: {path}") print("Copy config.yaml.example to config.yaml and fill in your instance URL and API token.") sys.exit(1) with path.open() as f: return yaml.safe_load(f) def build_client(sisense_cfg: dict) -> SisenseClient: url = sisense_cfg.get("url", "") token = sisense_cfg.get("token", "") if not url or not token: print("config.yaml must include sisense.url and sisense.token.") sys.exit(1) is_ssl = not url.startswith("http://") return SisenseClient(domain=url, token=token, is_ssl=is_ssl) # Name matching — mirrors pySisense internals, used for dry-run previews and # strict-mode computation (pySisense handles matching internally for real calls) def _normalize(name: str) -> str: lower = name.lower() return lower[len("plugin-"):] if lower.startswith("plugin-") else lower def _build_lookup(names: list) -> set: lookup: set = set() for n in names: norm = _normalize(n) lookup.add(norm) lookup.add("plugin-" + norm) return lookup def _matches_lookup(plugin: dict, lookup: set) -> bool: return bool({plugin["folderName"].lower(), plugin["name"].lower()} & lookup) # Snapshot file I/O def write_snapshot_file(name: str, snapshot: dict) -> Path: SNAPSHOTS_DIR.mkdir(exist_ok=True) path = SNAPSHOTS_DIR / f"{name}.yaml" with path.open("w") as f: yaml.dump(snapshot, f, sort_keys=False, default_flow_style=False, allow_unicode=True) return path def read_snapshot_file(name: str) -> dict: path = SNAPSHOTS_DIR / f"{name}.yaml" if not path.exists(): print(f"Snapshot not found: {path}") sys.exit(1) with path.open() as f: data = yaml.safe_load(f) # Translate 'enable_plugins' key (plugin_manager.py snapshot format) to 'plugins' if "enable_plugins" in data and "plugins" not in data: data["plugins"] = data["enable_plugins"] return data def list_snapshot_files() -> list: if not SNAPSHOTS_DIR.exists(): return [] return sorted(SNAPSHOTS_DIR.glob("*.yaml")) def latest_snapshot_name() -> str: files = list_snapshot_files() if not files: print("No snapshots found.") sys.exit(1) def created_at(path: Path) -> str: with path.open() as f: return yaml.safe_load(f).get("created", "") return max(files, key=created_at).stem def _auto_save_snapshot(all_plugins: list, include_version: bool) -> None: name = datetime.now().strftime("auto_%Y%m%d_%H%M%S") print(f"\nSaving snapshot '{name}' before applying changes...") try: enabled = sorted(p["folderName"] for p in all_plugins if p.get("isEnabled")) disabled = sorted(p["folderName"] for p in all_plugins if not p.get("isEnabled")) snapshot: dict = { "plugins": enabled, "created": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), } if include_version: versions: dict = {} for p in all_plugins: v = p.get("version") or p.get("pluginVersion") if v: versions[p["folderName"]] = str(v) if versions: snapshot["plugin_versions"] = versions path = write_snapshot_file(name, snapshot) print(f" Saved to {path} ({len(enabled)} enabled / {len(disabled)} disabled)") except Exception as exc: print(f" Warning: auto snapshot failed: {exc}") # Sync computation — determines which plugins need their state changed. # Used for dry-run previews, strict mode, and enable-one. def _compute_updates( all_plugins: list, enable_names: list, disable_names: list, *, strict: bool, enable_only: bool, disable_only: bool, ) -> tuple: enable_lookup = _build_lookup(enable_names) disable_lookup = _build_lookup(disable_names) matched_enable = [p for p in all_plugins if _matches_lookup(p, enable_lookup)] enable_folder_set = {p["folderName"] for p in matched_enable} to_enable: list = [] if disable_only else sorted( p["folderName"] for p in matched_enable if not p.get("isEnabled") ) if enable_only: to_disable: list = [] elif strict: to_disable = sorted( p["folderName"] for p in all_plugins if p.get("isEnabled") and p["folderName"] not in enable_folder_set ) else: to_disable = sorted( p["folderName"] for p in all_plugins if p.get("isEnabled") and _matches_lookup(p, disable_lookup) ) matched_norms = ( {_normalize(p["folderName"]) for p in matched_enable} | {_normalize(p["name"]) for p in matched_enable} ) unmatched = sorted(n for n in enable_names if _normalize(n) not in matched_norms) return to_enable, to_disable, unmatched # Output helpers def _print_update_plan( to_enable: list, to_disable: list, already_correct: int, untouched: int = 0, ) -> None: print(f" To enable: {len(to_enable)}") print(f" To disable: {len(to_disable)}") if already_correct > 0: print(f" Already set: {already_correct}") if untouched > 0: print(f" Untouched: {untouched} (not listed in config; left as-is)") print() def _print_dry_run_preview(to_enable: list, to_disable: list) -> None: if not to_enable and not to_disable: print("Nothing to change.") if to_enable: print("Would enable:") for f in to_enable: print(f" + {f}") if to_disable: print("Would disable:") for f in to_disable: print(f" - {f}") print("\n[DRY RUN] No changes made.") def _report_unmatched(unmatched: list) -> None: if unmatched: print(f"\nNot found in instance ({len(unmatched)}):") for n in unmatched: print(f" - {n}") def _apply_enable(plugins_obj: Plugins, to_enable: list, bulk: bool) -> int: if not to_enable: return 0 result = plugins_obj.enable_plugins(to_enable, bulk=bulk) if "error" in result: print(f"Failed to enable plugins: {result['error']}") sys.exit(1) for f in result["changed"]: print(f" [ENABLED] {f}") return len(result.get("errors", [])) def _apply_disable(plugins_obj: Plugins, to_disable: list, bulk: bool) -> int: if not to_disable: return 0 result = plugins_obj.disable_plugins(to_disable, bulk=bulk) if "error" in result: print(f"Failed to disable plugins: {result['error']}") sys.exit(1) for f in result["changed"]: print(f" [DISABLED] {f}") return len(result.get("errors", [])) # Commands def cmd_sync_to_config( plugins_obj: Plugins, enable_names: list, disable_names: list, *, dry_run: bool, bulk: bool, strict: bool, enable_only: bool, disable_only: bool, skip_snapshot: bool, include_version: bool, ) -> None: print("Fetching plugin list...") all_plugins = plugins_obj.get_all_plugins() if all_plugins and "error" in all_plugins[0]: print(f"Failed to fetch plugins: {all_plugins[0]['error']}") sys.exit(1) print(f" {len(all_plugins)} plugins total\n") to_enable, to_disable, unmatched = _compute_updates( all_plugins, enable_names, disable_names, strict=strict, enable_only=enable_only, disable_only=disable_only, ) enable_lookup = _build_lookup(enable_names) disable_lookup = _build_lookup(disable_names) matched = [p for p in all_plugins if _matches_lookup(p, enable_lookup)] already_correct = sum(1 for p in matched if p.get("isEnabled")) untouched = 0 if strict or enable_only else sum( 1 for p in all_plugins if not _matches_lookup(p, enable_lookup) and not _matches_lookup(p, disable_lookup) ) if matched: print(f"Matched {len(matched)} plugin(s) from enable_plugins:\n") print(f" {'Folder name':<45} {'API name':<35} {'Enabled'}") print(f" {'-'*45} {'-'*35} {'-'*7}") for p in sorted(matched, key=lambda x: x["folderName"]): print(f" {p['folderName']:<45} {p['name']:<35} {p['isEnabled']}") print() _print_update_plan(to_enable, to_disable, already_correct, untouched) if dry_run: _print_dry_run_preview(to_enable, to_disable) _report_unmatched(unmatched) return if not skip_snapshot and (to_enable or to_disable): _auto_save_snapshot(all_plugins, include_version) errors = _apply_disable(plugins_obj, to_disable, bulk) + _apply_enable(plugins_obj, to_enable, bulk) if not to_enable and not to_disable: print("Nothing to change.") else: print(f"\nDone. Enabled: {len(to_enable)} Disabled: {len(to_disable)} Errors: {errors}") _report_unmatched(unmatched) def cmd_enable_one( plugins_obj: Plugins, plugin_name: str, *, dry_run: bool, bulk: bool, skip_snapshot: bool, include_version: bool, ) -> None: target_lookup = _build_lookup([plugin_name]) print("Fetching plugin list...") all_plugins = plugins_obj.get_all_plugins() if all_plugins and "error" in all_plugins[0]: print(f"Failed to fetch plugins: {all_plugins[0]['error']}") sys.exit(1) print(f" {len(all_plugins)} plugins total\n") target = next((p for p in all_plugins if _matches_lookup(p, target_lookup)), None) if target is None: print(f"Plugin not found: {plugin_name!r}") print("Tip: check spelling against the Sisense admin panel, or run --save-snapshot to capture current names.") sys.exit(1) target_folder = target["folderName"] target_on = target.get("isEnabled", False) to_enable = [] if target_on else [target_folder] to_disable = sorted( p["folderName"] for p in all_plugins if p.get("isEnabled") and p["folderName"] != target_folder ) print(f"Target plugin: {target_folder}") print(f" Currently: {'enabled' if target_on else 'disabled'}\n") _print_update_plan(to_enable, to_disable, already_correct=1 if target_on else 0) if dry_run: _print_dry_run_preview(to_enable, to_disable) return if not skip_snapshot and (to_enable or to_disable): _auto_save_snapshot(all_plugins, include_version) errors = _apply_disable(plugins_obj, to_disable, bulk) + _apply_enable(plugins_obj, to_enable, bulk) if not to_enable and not to_disable: print("Nothing to change.") else: print(f"\nDone. Enabled: {len(to_enable)} Disabled: {len(to_disable)} Errors: {errors}") def cmd_save_named_snapshot(plugins_obj: Plugins, name: str, include_version: bool) -> None: print("Fetching plugin list...") all_plugins = plugins_obj.get_all_plugins() if all_plugins and "error" in all_plugins[0]: print(f"Failed to fetch plugins: {all_plugins[0]['error']}") sys.exit(1) print(f" {len(all_plugins)} plugins total\n") enabled = sorted(p["folderName"] for p in all_plugins if p.get("isEnabled")) disabled = sorted(p["folderName"] for p in all_plugins if not p.get("isEnabled")) snapshot: dict = { "plugins": enabled, "created": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), } if include_version: versions: dict = {} for p in all_plugins: v = p.get("version") or p.get("pluginVersion") if v: versions[p["folderName"]] = str(v) if versions: snapshot["plugin_versions"] = versions path = write_snapshot_file(name, snapshot) print(f"Snapshot '{name}' saved to {path}") print(f" {len(enabled)} enabled / {len(disabled)} disabled plugin(s) captured.") if include_version and snapshot.get("plugin_versions"): print(f" Versions recorded for {len(snapshot['plugin_versions'])} plugin(s).") def cmd_list_snapshots() -> None: files = list_snapshot_files() if not files: print("No snapshots found.") return print(f"Snapshots ({len(files)}):\n") print(f" {'Name':<32} {'Created':<22} {'Enabled':<9} {'Versions'}") print(f" {'-'*32} {'-'*22} {'-'*9} {'-'*8}") for path in files: with path.open() as f: data = yaml.safe_load(f) enabled_count = len(data.get("plugins") or data.get("enable_plugins", [])) versions_logged = "yes" if data.get("plugin_versions") else "no" print( f" {path.stem:<32} " f"{data.get('created', 'unknown'):<22} " f"{enabled_count:<9} " f"{versions_logged}" ) def cmd_restore_snapshot( plugins_obj: Plugins, name: str, *, dry_run: bool, bulk: bool, enable_only: bool, disable_only: bool, skip_snapshot: bool, include_version: bool, ) -> None: snapshot = read_snapshot_file(name) snapshot_plugins = set(snapshot.get("plugins", [])) print(f"Snapshot '{name}' ({snapshot.get('created', 'unknown')})") print(f" {len(snapshot_plugins)} plugin(s) enabled in snapshot") if snapshot.get("plugin_versions"): print(f" Plugin versions recorded: {len(snapshot['plugin_versions'])}") print() print("Fetching plugin list...") all_plugins = plugins_obj.get_all_plugins() if all_plugins and "error" in all_plugins[0]: print(f"Failed to fetch plugins: {all_plugins[0]['error']}") sys.exit(1) print(f" {len(all_plugins)} plugins total\n") by_folder = {p["folderName"]: p for p in all_plugins} to_enable: list = [] if disable_only else sorted( f for f in snapshot_plugins if f in by_folder and not by_folder[f].get("isEnabled") ) to_disable: list = [] if enable_only else sorted( p["folderName"] for p in all_plugins if p.get("isEnabled") and p["folderName"] not in snapshot_plugins ) not_in_instance = sorted(f for f in snapshot_plugins if f not in by_folder) already_correct = max(0, len(snapshot_plugins) - len(to_enable) - len(not_in_instance)) _print_update_plan(to_enable, to_disable, already_correct) if dry_run: _print_dry_run_preview(to_enable, to_disable) if not_in_instance: print(f"\nNot found in instance ({len(not_in_instance)}):") for f in not_in_instance: print(f" - {f}") return if not skip_snapshot and (to_enable or to_disable): _auto_save_snapshot(all_plugins, include_version) errors = _apply_disable(plugins_obj, to_disable, bulk) + _apply_enable(plugins_obj, to_enable, bulk) if not to_enable and not to_disable: print("Nothing to change.") else: print(f"\nDone. Enabled: {len(to_enable)} Disabled: {len(to_disable)} Errors: {errors}") if not_in_instance: print(f"\nNot found in instance ({len(not_in_instance)}):") for f in not_in_instance: print(f" - {f}") # Main def main() -> None: import argparse parser = argparse.ArgumentParser( prog="plugin_manager_pysisense.py", description="Manage Sisense plugin states via pySisense.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "Plugin names accept the short form (SwitchDimension) or the full\n" "form (plugin-SwitchDimension) in both CLI args and config.yaml.\n" "\n" "Default behavior: enable plugins in enable_plugins, disable plugins\n" "in disable_plugins, leave everything else unchanged.\n" "Use --strict to also disable any enabled plugin not in enable_plugins.\n" "\n" "A snapshot is saved automatically before each run that\n" "modifies state. Use --skip-snapshot to suppress this." ), ) parser.add_argument( "--config", default=str(DEFAULT_CONFIG), metavar="FILE", help="Path to config.yaml (default: config.yaml next to this script)", ) parser.add_argument( "--dry-run", action="store_true", help="Preview what would change; make no API calls and save no snapshot", ) parser.add_argument( "--no-bulk", action="store_true", help=( "Send one API call per plugin instead of a single batch PATCH. " "Slower but easier to diagnose partial failures. " "Overrides sisense.bulk in config." ), ) parser.add_argument( "--skip-snapshot", action="store_true", help="Do not automatically save a snapshot before this run", ) parser.add_argument( "--log-version", action="store_true", help=( "Record each plugin's version field (from the API response) in any " "snapshot saved during this run (manual --save-snapshot or auto saved snapshot)" ), ) parser.add_argument( "--strict", action="store_true", help=( "Also disable every currently enabled plugin that is not listed in " "enable_plugins. Without this flag, only plugins in disable_plugins are " "disabled; everything else is left alone. " "Always active for --restore-snapshot." ), ) direction_group = parser.add_mutually_exclusive_group() direction_group.add_argument( "--enable-only", action="store_true", help="Run only the enable step; skip all disables", ) direction_group.add_argument( "--disable-only", action="store_true", help="Run only the disable step; skip all enables", ) mode_group = parser.add_mutually_exclusive_group() mode_group.add_argument( "--enable-one", metavar="PLUGIN", help=( "Enable exactly one plugin by name and disable all currently enabled " "others. Accepts the short or full plugin name." ), ) mode_group.add_argument( "--save-snapshot", metavar="NAME", nargs="?", const="__auto__", help=( "Save the current plugin state as a named snapshot. " "Omit NAME for an auto generated timestamp filename. " "Combine with --log-version to also record versions for each plugin." ), ) mode_group.add_argument( "--list-snapshots", action="store_true", help="List all saved snapshots with creation date and enabled plugin count", ) mode_group.add_argument( "--restore-snapshot", metavar="NAME", nargs="?", const="__latest__", help=( "Restore plugin state to exactly match a saved snapshot. " "Omit NAME to use the most recently created snapshot. " "Always strict (disables plugins not in snapshot) unless --enable-only." ), ) args = parser.parse_args() if args.list_snapshots: cmd_list_snapshots() return cfg = load_config(Path(args.config)) sisense_cfg = cfg.get("sisense", {}) bulk = sisense_cfg.get("bulk", True) and not args.no_bulk client = build_client(sisense_cfg) plugins_obj = Plugins(api_client=client) if args.enable_one: cmd_enable_one( plugins_obj, args.enable_one, dry_run=args.dry_run, bulk=bulk, skip_snapshot=args.skip_snapshot, include_version=args.log_version, ) elif args.save_snapshot is not None: name = ( datetime.now().strftime("snapshot_%Y%m%d_%H%M%S") if args.save_snapshot == "__auto__" else args.save_snapshot ) cmd_save_named_snapshot(plugins_obj, name, args.log_version) elif args.restore_snapshot is not None: name = ( latest_snapshot_name() if args.restore_snapshot == "__latest__" else args.restore_snapshot ) cmd_restore_snapshot( plugins_obj, name, dry_run=args.dry_run, bulk=bulk, enable_only=args.enable_only, disable_only=args.disable_only, skip_snapshot=args.skip_snapshot, include_version=args.log_version, ) else: enable_names = cfg.get("enable_plugins") or cfg.get("plugins", []) disable_names = cfg.get("disable_plugins", []) if not enable_names and not disable_names: print("config.yaml must include at least one entry in 'enable_plugins'.") sys.exit(1) cmd_sync_to_config( plugins_obj, enable_names, disable_names, dry_run=args.dry_run, bulk=bulk, strict=args.strict, enable_only=args.enable_only, disable_only=args.disable_only, skip_snapshot=args.skip_snapshot, include_version=args.log_version, ) if __name__ == "__main__": main() Disclaimer: This post outlines a potential custom workaround for a specific use case or provides instructions regarding a specific task. The solution may not work in all scenarios or Sisense versions, so we strongly recommend testing it in your environment before deployment. If you need further assistance with this, please let us know.

      Jeremy Friedel
      Jeremy FriedelPosted 3 months ago • Last reply 3 months ago
      1
               
      • How-Tos & FAQsChevronRightIcon

      LAG / LEAD Functions

                               

      Question LAG & LEAD Functions are functions that give access to multiple rows within a table, without the need for a self-join. The LAG function is used to access data from a previous row, while the LEAD function is used to return data from rows further down the result set. A  perfect example of these functions in use is with the “Sales Order” scenario. A department store is trying to monitor the purchase trends of its customers; more specifically, per customer, what’s the average time it took to repeat an order? For us to do so, we must take a record in the table (Order Date) and compare it to another record in the same table. What are some ways we can do that? In our example, we will analyze & compare the efficiency between the multiple solutions. We will start by addressing the problem in the most common/traditional of solving a problem like this. Answer   Steps: 1. Create A New ElastiCube Add Data > Microsoft SQL Server > Connect To Server Pull in Sales Order Header Table "How are we going to manipulate that table to get it into a format that we would want to work with?" Uncheck fields that you don’t need; looking for high-level/important data   BUILD! 2. Let's Create A Custom SQL Expression   What we are getting here a single customer’s activity.  It also orders the Order Date in ascending order (from first date to last) "For this specific customer, I want to be able to see __ amount of days between each order. How can we compute / compare those dates? In order to do so, we want to take the order table and join it to itself, where the customer is the same and the order date is offset to the previous or future date." So, let’s start by leveraging the rank function to assign an order number to every date… The Rank function is important here because within the Customer ID, we want to rank based off the order date, group it per customer, and order by the order date. This basically creates create a system that makes it so that it is comparing itself to itself minus 1. This is what sets up our ranking per order. In order for us to join the table to itself, let’s create a sub query and inside of it, put the entire Customer’s table: (Type Of Join:) What it’s getting joined on: Result: 3. Now That We Have The Order Date & The Total Due, We Can Start Subtracting Them Off Each Other & Start Doing Differences Now you would want to go back to the top; the first “Select” of the sub query and change the claims to: Preview: Now that we saw that this function is working, we can now delete our customer filter, order by filter. Now we get all the differences across all of the customers. Issue With This Solution The main issue with this solution is that it requires a lot of processing due to the two tables. Although we are really using one table, we still have two and the same data is repeated between them. This extra storage isn’t necessary, creates the long processing, and can also create confusion for the customer. Solution 2: Doing A Lookup Function On Itself (Own Data Set). To improve upon the last solution, is there a way to pull in one table and do the manipulation in Sisense, without creating a second table? Answer: Yes! What we can do is take the custom table and add it as a view to the source; that way we only import one table Steps: 1. Duplicate "SalesOrderHeader 1", Rename "SalesOrderHeader 2" BUILD! For us to do a look up on this table, we need to have need to have one key to join.  The key is a combination of the customer ID + ranking, that’s what makes it unique. 2. So First, Create A New Field, Record_Rank (Int),  Input: BUILD Add Data > Custom SQL Table   3. Head Back Into SalesOrderHeader 2, And Set Up The Different Fields To Create The Key + New Field > CurrCustomerRankKey (Text) Do Entire Build 4. Let's Create A Test Function + Custom SQL Expression > Test Function   5. Go Back To SalesOrderHeaders 2 Table, Do Lookup In There + New Field > PrevOrderDate (Date-Time) + New Field > PrevTotalDue (Float) + New Field > DaysBetweenOrders(Int) + New Field > DiffOrderAmount Schema Build: Solution 3: LAG & LEAD Functions This solution does not include a self-join Steps: 1. Open Up SQL Server (TC SQL) Go to same database (Adventure Works 2012) > create new query Let’s start off the new query as: Now we want to dynamically compare one against the other. Instead of doing a self-join, it tells itself to look at the look at the record/number that is above or below and just pull that value in. So it’s almost like a lookup , but it doesn’t require a join , it just requires you to tell it how to sort the data and just dynamically go up and go down and pull the field that you want in to the current record. This is where the LAG & LEAD functions step in To go back a record, we call the LAG function. Within the LAG function, we must define the field that we want to pull in (order date) and how many records back do you want to go (1). Next we want to group by (partition by) and Order By a type of variety and then order it either ASC / DES. To make our “look up” a bit more distinct we can Result: If we want to compare up against future dates, we would use: And its result would be: What we can also pull in the Total Due and calculate the previous Total Due: Which would require a sub query: To get of the first null, we would enter: Pull in everything from t2.* and now we can calculate our differences  (Date Diff, etc): As for getting this entire query into Sisense, we want to create a View

      Community_Admin
      Community_AdminPosted 4 years ago • Last reply 3 months ago
      1
               
    • Blog banner
      • Widget & Dashboard ScriptsChevronRightIcon

      Hiding Widgets if a Widget Has No Results [Linux]

                                                                                                               

      Hiding Widgets if a Widget Has No Results [Linux] Introduction A common dashboard design requirement is hiding widgets that have no data. This article includes a widget script that conditionally hides an indicator widget when its primary value is empty or represents an N/A like value. This behavior is commonly requested when dashboard filters or formulas result in no meaningful value and the widget should be visually hidden rather than showing an empty or zero indicator. The article also includes an alternative dashboard-level approach that hides widgets based on filter selections, without inspecting widget query results. This alternative is derived from the linked external blog post . This is applicable to both on-cloud and on-prem Sisense in all recent Sisense versions. Use Case Customers often want indicator widgets to disappear when their calculated value is not meaningful. Common examples include: Filters resulting in no matching data Calculations returning N/A, null, or empty values Conditional metrics that only apply to certain filter selections Rather than showing an empty indicator, this approach hides the widget entirely and restores it automatically when the value becomes valid again. Layout Considerations For best visual results, it is ideal to use one of these two layouts for the indicator widgets that may be hidden: Place the indicator widget in its own dashboard row Place it at the end of a row Hiding a widget via a script does not automatically resize or reflow other widgets on the same row. Step by Step Guide Widget Script to Hide Widget Based on Indicator Value This widget script is applied directly to the indicator widget. It evaluates the widget’s primary value after each render and hides or shows the widget accordingly. Behavior If the primary value is null, empty, or an N/A like string, the widget container is set to display: none. When the value becomes valid and not null again, the widget is restored to visibility and redrawn to ensure correct indicator rendering. A guard variable prevents infinite redraw loops, since a redraw triggers the widget ready event. A debug flag allows optional console logging when needed. Widget Script /** * Hide widget when its primary indicator value is empty or N/A-like. * * Behavior: * - If the value is empty, set the widget container to display "none". * - If the returned value becomes a number or valid value, restore display style to original and redraw * - A redraw triggers "ready" again, so a guard variable prevents a redraw loop. * * Debug: * - Set debug variable = true to enable console logging to track script status. * * For best results use on indicator in own row or at end of row, if other widgets exist on row, empty space will appear in dashboard */ widget.on("ready", function () { function run() { var debug; var suppressNextReady; var hideValues; debug = false; suppressNextReady = false; hideValues = [ "n/a", "#n/a", "na", "none", "null", "undefined" ]; // Function to turn console logging on or off function log(message, data) { if (!debug) { return; } if (data === undefined) { console.log("[hide-empty-indicator] " + message); return; } console.log("[hide-empty-indicator] " + message, data); } // Widget CSS selector function getWidgetElement() { return document.querySelector('widget[widgetid="' + widget.oid + '"]'); } // Get value of primary indicator value function getPrimaryValue() { if ( widget.queryResult && widget.queryResult.value && widget.queryResult.value.data !== undefined ) { return widget.queryResult.value.data; } try { if ( widget.queryResult && widget.queryResult.data && widget.queryResult.data.length ) { return widget.queryResult.data[0][0]; } } catch (e) { log("Value read failed for data[0][0].", e); } if (widget.queryResult && Array.isArray(widget.queryResult)) { if (widget.queryResult.length && widget.queryResult[0].length) { if (widget.queryResult[0][0]) { return widget.queryResult[0][0].Value; } } } return null; } function shouldHide(value) { var text; var normalized; if (value === null || value === undefined) { return true; } text = String(value).trim(); if (!text) { return true; } normalized = text.replace(/\\/g, "/").toLowerCase(); return hideValues.indexOf(normalized) !== -1; } function isElementHidden(element) { if (!element) { return false; } return element.style.display === "none"; } function hideWidget(element) { if (!element) { return; } if (element.style.display === "none") { return; } element.style.display = "none"; log("Hid widget due to empty/N/A-like value."); } function showWidgetAndRedrawIfNeeded(element) { var wasHidden; if (!element) { return; } wasHidden = isElementHidden(element); element.style.display = ""; if (!wasHidden) { log("Widget already visible."); return; } if (typeof widget.redraw !== "function") { log("Widget restored, redraw not available."); return; } suppressNextReady = true; log("Widget restored, triggering redraw."); widget.redraw(); } function applyRule() { var element; var primaryValue; element = getWidgetElement(); if (!element) { log("Widget container element not found."); return; } if (widget.queryResult === undefined) { log("queryResult is not available yet, keeping widget visible."); element.style.display = ""; return; } primaryValue = getPrimaryValue(); log("Primary value evaluated.", primaryValue); if (shouldHide(primaryValue)) { hideWidget(element); return; } showWidgetAndRedrawIfNeeded(element); } function onReady() { if (suppressNextReady) { suppressNextReady = false; log("Ready fired after redraw, applying rule without redraw."); applyRule(); return; } applyRule(); } onReady(); } run(); }); Notes The script uses display: none instead of jQuery hide or show to avoid layout and rendering issues with indicator widgets. Redraw is triggered only when restoring visibility, not when hiding. The script relies only on the widget ready event, which fires again after redraw and filter changes. Dashboard Script to Hide Widgets Based on Filter Selections As an alternative, widgets can be hidden purely based on filter selections, without checking whether the widget returns data. This approach is useful when visibility rules are deterministic based on filters. This method uses a dashboard script and CSS classes to hide widget containers. Example Dashboard Script dashboard.on('filterschanged', function (se, ev) { let filterName = 'Region' //mapping of filter items and widgets to be hidden. //if selected filter item is not available in the list, widgets in 'default' key will be hidden let itemWidgetMapping = { 'Midwest':['6390b5a285a029002e9e2ad6'], 'South': ['6238887ba77683002ea4425b'], 'West':['6390b5a285a029002e9e2ad6', '6238887ba77683002ea4425b'], 'default':[] } selectedFilter = ev.items.find(el=>el.jaql.title == filterName) let selectedItem = 'default' if(selectedFilter && selectedFilter.jaql.filter.members) selectedItem = selectedFilter.jaql.filter.members[0] //unhide all widgets first and then hide widgets based on selected filter $(`widget`).closest('.dashboard-layout-subcell-host').removeClass('dontshowme-parent') if(selectedItem in itemWidgetMapping){ for (const [key, value] of Object.entries(itemWidgetMapping)) { if(key == selectedItem){ itemWidgetMapping[key].forEach(function (item, index) { $(`widget[widgetid="${item}"]`).closest('.dashboard-layout-subcell-host').addClass('dontshowme-parent') }); } } } else{ itemWidgetMapping['default'].forEach(function (item, index) { $(`widget[widgetid="${item}"]`).closest('.dashboard-layout-subcell-host').addClass('dontshowme-parent') }); } }); Choosing the Right Approach Generally the widget script is best suited when: Visibility depends on whether data is returned The indicator value can be empty due to calculations or filters Generally the dashboard script is best suited when: Visibility depends only on filter selections Centralized control over multiple widgets is required These approaches are alternatives and should not be used simultaneously for the same widgets. Conclusion Hiding indicator widgets based on their returned value can potentially improve dashboard clarity and user experience. The widget script approach provides result aware behavior, while the dashboard script approach offers deterministic, filter based control. Both methods are powerful tools for customizing dashboard widget visibility. Two Full Row Indicator Widgets, both visible First Row Indicator Widget is now hidden, by script, due to no data Two Indicators in one row, both visible Second Indicator is now hidden by script, due to no data Disclaimer: This post outlines a potential custom workaround for a specific use case or provides instructions regarding a specific task. The solution may not work in all scenarios or Sisense versions, so we strongly recommend testing it in your environment before deployment. If you need further assistance with this, please let us know.

      Jeremy Friedel
      Jeremy FriedelPosted 3 months ago
      0
               
    • Idea
      • Help and How-ToChevronRightIcon
      Your Sisense charts are clean, but are your tooltips telling the whole story?
                                                                                               
      Mia Isaacson
      Mia IsaacsonPosted 3 months ago
               
      0
               
    • Blog banner
      • News & UpdatesChevronRightIcon

      Choosing the Right Replication Strategy: Evaluating ADF + CDC vs. Snowflake Openflow

                                                       

      If you're delivering embedded analytics through Sisense, your dashboard is only as good as the data behind it. But how often do you think about what's happening upstream in the pipelines and ingestion architectures that determine whether that data is fresh, reliable, and cost-efficient before it ever reaches a visualization? QBeeQ is known in the Sisense ecosystem as a plugin and integration provider, but that's only part of what we do. This post is a window into the broader, strategic side of our work: architectural decision-making that happens well upstream of the BI layer but directly impacts the analytics experience your customers see. The client here uses Sisense to deliver embedded dashboards. We built their Snowflake-based data platform from the ground up:  integrations, ELT, data modeling, and governance with Sisense sitting at the top of the stack. The next question became: is our data replication approach from MS SQL Server into Snowflake the right one long-term, or should we move to Snowflake Openflow? A Platform Already in Motion Our relationship with our client didn't start with this POC. We previously helped them design and build a Snowflake-based data platform from the ground up, including data integrations, ELT processes, data models, and governance. That work gave them a solid, modern foundation for their analytics and reporting, including the embedded dashboards they deliver to their customers via Sisense. This POC was the next strategic step in that journey. With the platform stable and maturing, the question became: are we using the best possible approach for replicating data from their MS SQL Server into Snowflake? And more specifically, should we continue developing our custom ingestion approach, or move toward a more ready-to-go solution using Snowflake Openflow? Our client’s business depends on near-real-time data availability. Their operational data lives in MS SQL Server and feeds directly into Snowflake, where it powers analytics and reporting for their customers across retail, telecom, banking, and energy.  These aren't just technical concerns. They directly affect the quality and reliability of the analytics experience delivered to its end customers. The key business drivers behind this evaluation were: Faster availability of incremental data, especially for large transactional tables Lower operational risk and improved robustness Reduced maintenance effort over time Cost optimization at scale Putting Two Approaches to the Test Rather than making a decision based on vendor documentation or assumptions, we designed a structured POC to test both approaches in parallel, on the same three tables and similar data volumes, across four dimensions:  performance cost stability maintainability Option 1: Custom ADF + SQL CDC (Our Existing Solution) The key strength of this approach is control. We define the schema, manage data types, handle transformations during ingestion, and have full visibility into every step of the pipeline. This is the approach we built as part of the initial platform work:  CDC (Change Data Capture) - a native SQL Server feature that tracks only changed rows (inserts, updates, and deletes), eliminating the need to reload entire tables on every run Azure Data Factory (ADF) - Microsoft's cloud orchestration service, used here to move delta data from SQL Server to Azure Blob Storage Snowflake - which then loads and transforms the data using Snowpipe and scheduled tasks It's more engineering-intensive to set up, but the result is a robust, predictable, and highly tunable architecture. Option 2: Snowflake Openflow Openflow is Snowflake's newer, more plug-and-play approach to data ingestion. It uses Change Tracking (CT) on the SQL Server side, a lighter-weight mechanism than CDC,  and ingests data more directly into Snowflake with less custom orchestration required. On paper, it's appealing: faster to configure, fewer moving parts, and tighter integration within the Snowflake ecosystem. But as with any "managed" solution, there are trade-offs. And that's exactly what we needed to quantify. What We Found Performance Both approaches handled initial loads equally well. The gap appeared with delta loads — Openflow's Change Tracking completed incremental updates in 1–2 minutes versus 5–12 minutes for ADF + CDC. For near-real-time use cases, that's a genuine advantage worth noting. Cost This is where the picture shifts dramatically, and where decision makers should pay close attention: Openflow consumed approximately 12.5 Snowflake credits per day, translating to roughly $50/day or ~$1,500/month for this workload alone.  ADF + CDC came in at an estimated $8–15/month   That's not a marginal difference. That's a 100x cost gap on a three-table POC. Now consider what that looks like at scale. Most production environments don't replicate three tables; they replicate dozens, sometimes hundreds. If costs scale proportionally, an Openflow-based architecture could run into tens of thousands of dollars per month for a full production workload, compared to what remains a very modest cost with the custom ADF approach. For a data platform that's meant to be a long-term foundation, the compounding effect of that cost difference is enormous. Over a single year, the gap between these two approaches could easily reach $200,000 or more — money that could instead fund additional data products, analytics capabilities, or engineering capacity. For decision makers evaluating build vs. buy, or assessing the true TCO of a "managed" solution, this kind of analysis is exactly what's needed before committing to a direction. Stability & Maturity The Openflow SQL Server connector was still in preview status at the time of the POC. Runtime and canvas updates caused instability during our testing, and the solution requires ongoing infrastructure coordination. Azure Data Factory, by contrast, is a mature and battle-tested technology with a proven track record in production environments. Architecture & Maintainability Openflow introduced meaningful architectural complexity: requires Snowflake Business Critical edition (for PrivateLink connectivity) adds authentication and data sharing overhead offers limited control over target data types, and  handles soft deletes only, so hard deletes require additional handling Additional Snowflake-side transformations (PARSE_JSON, CLEAN_JSON_DUPLICATES) were also necessary. Our custom ADF solution, while more involved to build: gives full control over schema and transformations  handles deletes cleanly can be extended through a reusable automation procedure that simplifies onboarding new tables over time   Open Flow ADF + CDC Architecture Complexity ⚠️ Requires Business Critical + PrivateLink ✅ Simpler, fewer dependencies Stability  ⚠️ Preview — instability during POC ✅ Mature, production-proven Cost/month ⚠️ ~$1,500 ✅ ~$8–15 Initial load ✅ Comparable ✅ Comparable Delta load speed ✅ ~1–2 min ⚠️ ~5–12 min Onboarding new tables ✅ Fast and easy ⚠️ More setup, but automatable Schema & type control ⚠️ Limited ✅ Full control Delete Handling ⚠️ Soft deletes only ✅ Full delete support Our Recommendation The POC gave our client something genuinely valuable: a data-driven basis for a strategic architectural decision, rather than a choice made on assumption or vendor marketing. Our recommendation leaned toward continuing with the custom ADF + CDC approach as the long-term foundation. Not because Openflow lacks merit, but because the cost differential is substantial, the stability risks are real at this stage of the product's maturity, and the architectural overhead introduces complexity that isn't justified by the performance gain alone. That said, the delta load performance advantage of Openflow is meaningful. As the connector matures and if near-real-time requirements intensify, it remains a viable path to revisit. The POC ensures that if and when that moment comes, the decision will be informed, not reactive. What This Kind of Work Looks Like in Practice In the Sisense ecosystem, you may know us through our plugins and integrations. But this post reflects something equally important: our ability to act as a full-stack data platform partner. What your users see in Sisense is a function of decisions made far upstream. When those decisions lack rigor, the effects show up as latency, data gaps, and rising costs. Running a structured POC like this is often underestimated, but is one of the most valuable things we do for our clients. It's not just about finding the faster or cheaper option. It's about understanding the full picture: performance under realistic conditions, true cost at scale, architectural implications, and long-term maintainability. Every organization's situation is different. The right ingestion architecture depends on your data volumes, latency requirements, existing tooling, team capabilities, and cost constraints.  If you're thinking about your broader data strategy, not just what Sisense can do, but whether everything underneath it is built right, that's a conversation we're well-positioned to have. Thinking about your data platform strategy? Let's talk . QBeeQ is data strategy consulting firm made up of former Sisense employees and customers. We are a Sisense Gold Implementation Partner and Snowflake Select Partner

      Mia Isaacson
      Mia IsaacsonPosted 4 months ago
      0
               
    • Blog banner
      • APIsChevronRightIcon

      Connection Tool - Programmatically Remove Unused Datasource Connections, and List All Connections

                                                                                                                                                                       

      Connection Tool - A Tool to Programmatically Remove Unused Datasource Connections, and List All Connections     Managing connections within your Sisense server can become complex over time, if there are a large number of connections, and connections are often added, and replace earlier datasource connections. In some scenarios unused connections can accumulate, potentially cluttering the Connection Manager UI with no longer relevant connections. Although unused connections typically represent minimal direct security risk, it's considered best practice to maintain a clean, organized list of connections, and in some scenarios it can be desired to remove all unused connections. Sisense prevents the deletion of connections actively used in datasources, safeguarding your dashboards and datasources from disruptions. However, inactive or "orphaned" connections remain after datasources are deleted or a connection is replaced, potentially contributing to unnecessary UI complexity in the connection manager UI. Connections can be of any type Sisense supports, common types include various SQL connections, Excel files, and CSV files, as well as many data providers, such as Big Panda. This tool can also be used to list all connections, with no automatic deletion of unused connections. Introducing the Sisense Connection Prune Tool The Sisense Connection Prune Tool is a Python-based Sisense API based tool designed to programmatically identify and delete unused connections. It generates a CSV report listing all connections and their associated datasources, streamlining your connection management process. If desired, it can automatically remove all unused connections automatically from a Sisense server.   Using the Tool, sourcing the Virtual Environment, generating the Connection CSV   CSV Output of Used Connections and Associated Datasources   CSV opened visually to view as Table, Excel and other programs and text editors can open CSV files   Sisense Connection Prune Tool README Here's the full README included with the tool: # Sisense Connection Prune Tool A command-line tool to list used data connections and prune unused Sisense connections via CSV. It allows you to generate a CSV file of all connections and their dependencies, then delete those connections if needed, after removing the connections to keep from the CSV. ## Features - **Dry Run Mode**: Simulate deletions without making any changes. - **CSV-Based Flow**: Easily inspect and list connections to remove before deletion. - **Logging**: Extensive logs if needed. - **Error Handling**: Clear and descriptive messages for issues encountered during execution. ## Usage 1. **Activate the Virtual Environment** After downloading this project folder, activate the Python virtual environment bundled with it that includes all Python dependencies. - **Windows**: `venv\Scripts\activate` - **macOS/Linux**: `source venv/bin/activate` 2. **Configure the Tool** Open the `config.yaml` file and set your Sisense server URL, bearer token, CSV file path, and log file path. For example: ```yaml server_url: "https://your.sisense.server" bearer_token: "your_bearer_token_here" dry_run: true csv_file_path: "connections.csv" log_file_path: "connection_tool.log" ``` - **server_url**: The URL of your Sisense instance. - **bearer_token**: Your Sisense API token for authentication. - **dry_run**: If set to `true`, deletions will be simulated (no real deletions). - **csv_file_path**: Where the CSV file should be created and read from. - **log_file_path**: Where log file will be stored. 3. **Run the Tool** ```bash python3 ConnectionPruneTool.py ``` You will be prompted to choose an option: 1. **Generate connection CSV** - Fetches all Sisense connections. - Immediately removes (or simulates removing, if `dry_run` is `true`) any connection with no dependencies. - Writes all remaining connections and their dependencies to the CSV file. - **Important**: Inspect the CSV file and remove lines for any connections you want to **keep**. 2. **Delete connections from CSV list** - Reads the CSV file. - Removes or simulates removing each connection still listed. - Provides a summary report of which connections were deleted or bypassed. 4. **Review the Logs** Check the file specified in `log_file_path` for a record of all actions taken or simulated if needed. This is helpful for understanding what happened during each run and diagnosing any issues. ## Example Workflow 1. **Generate CSV** ```bash python3 ConnectionPruneTool.py # Choose option 1 when prompted ``` After generation, open the CSV file and **delete rows** corresponding to any connections you want to **keep**. 2. **Delete Connections** ```bash python3 ConnectionPruneTool.py # Choose option 2 when prompted ``` The tool will read the CSV and delete the remaining listed connections (or simulate deletion, if `dry_run` is enabled). ## Notes - Unused connections are removed automatically in step 1, without a CSV step - To keep a connection, remove its line from the CSV before proceeding with deletion. - If `dry_run` is set to `true`, no actual deletions will occur, only simulated logs and printed messages. - The log file will be cleared at the start of each run, so be sure to review or archive logs (or change log file name in config), if needed. ​ This is a command-line tool to list used data connections and prune unused Sisense connections, in general and via a CSV list. It allows a user with a data admin or higher bearer token to generate a CSV file of all connections and their dependencies, then delete those connections if needed, from the remaining connections in the CSV. Example Output: Deleted unused connection: Old_DB_Connection (ID: 123abc) CSV file generated at connections.csv. It contains 25 row(s) of active connections. Please review and remove lines for connections you want to keep before running deletion step by running tool again. Remaining lines will be deleted in deletion mode. Summary Report: Total lines in CSV (active used connections): 25 Deleted Unused Connections: - Old_DB_Connection No connections were bypassed.   API Endpoints Used Retrieve connections: GET /api/v2/connections Retrieve dependencies: GET /api/v2/connections/{connection_id}/getAllDependencies Delete connection: DELETE /api/v2/connections/{connection_id} Full Code connections.py - Uses Sisense API endpoints to: Fetch all connections (GET /api/v2/connections). Retrieve datasource dependencies for a specific connection (GET /api/v2/connections/{connection_id}/getAllDependencies). Delete a specific connection (DELETE /api/v2/connections/{connection_id}).     from helperFunctions import load_config, api_get, api_delete config = load_config() headers = {"Authorization": f"Bearer {config['bearer_token']}"} base_url = config["server_url"] # Retrieve all connections from Sisense def get_all_connections(): endpoint = f"{base_url}/api/v2/connections" return api_get(endpoint, headers) # Retrieve dependencies of a specific connection def get_connection_dependencies(connection_id): endpoint = f"{base_url}/api/v2/connections/{connection_id}/getAllDependencies" return api_get(endpoint, headers) # Delete a specific connection def delete_connection(connection_id): endpoint = f"{base_url}/api/v2/connections/{connection_id}" return api_delete(endpoint, headers) helperFunctions.py - Uses the Requests library to handle API requests (GET/DELETE). Catches and logs API errors. Uses PyYAML to read the config.yaml file for configuration.     import yaml import requests import logging # Load configuration from YAML file def load_config(): with open("config.yaml", "r") as file: return yaml.safe_load(file) # Configure logging settings def setup_logging(log_file): logging.basicConfig( filename=log_file, level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", ) # Perform a GET request with error handling def api_get(endpoint, headers): try: response = requests.get(endpoint, headers=headers, verify=False) response.raise_for_status() return response.json() except requests.RequestException as e: logging.error(f"GET request failed: {e}") print(f"Error during GET request: {e}") return None # Perform a DELETE request with error handling def api_delete(endpoint, headers): try: response = requests.delete(endpoint, headers=headers, verify=False) response.raise_for_status() return response except requests.RequestException as e: logging.error(f"DELETE request failed: {e}") print(f"Error during DELETE request: {e}") return None ConnectionPruneTool.py - Serves as the entry point for the entire tool. Implements a CLI for user interaction. Invokes connections.py and helperFunctions.py to retrieve, analyze, and optionally delete Sisense connections. Uses Pandas to build a DataFrame of connection details and writes the results to a CSV file. Reads back the CSV file to remove selected connections after manual edits. Manages logs and prints summary reports to the terminal. import pandas as pd import logging import os from connections import ( get_all_connections, get_connection_dependencies, delete_connection, ) from helperFunctions import load_config, setup_logging # Load config yaml config = load_config() setup_logging(config["log_file_path"]) # Clear log file at start open(config["log_file_path"], "w").close() dry_run = config["dry_run"] csv_path = config["csv_file_path"] def generate_connections_csv(): """ Retrieves all connections from Sisense, deletes any that have no dependencies (unless in dry_run mode), and writes the remaining used connections and their dependent data models to a CSV file. """ # Create or clear existing CSV open(csv_path, "w").close() connections = get_all_connections() if connections is None: logging.error("Failed to retrieve connections.") print("Failed to retrieve connections.") return data = [] deleted = [] bypassed = [] # Go through each connection returned from the API for conn in connections: dependencies = get_connection_dependencies(conn["oid"]) if dependencies is None: logging.warning( f"Failed to retrieve dependencies for: {conn['name']} ({conn['oid']})" ) print( f"Failed to retrieve dependencies for: {conn['name']} ({conn['oid']})" ) bypassed.append(conn["name"]) continue # If no dependencies, optionally delete the connection (not in dry-run) if not dependencies: action_msg = "[Dry Run] Would delete" if dry_run else "Deleted" log_str = ( f"{action_msg} unused connection: {conn['name']} (ID: {conn['oid']})" ) print(log_str) logging.info(log_str) if not dry_run: try: response = delete_connection(conn["oid"]) # If response is None, treat it as a failed deletion if response is None: logging.error( f"Failed to delete unused connection: {conn['name']} ({conn['oid']})." ) print( f"Error deleting unused connection: {conn['name']} ({conn['oid']})" ) bypassed.append(conn["name"]) else: deleted.append(conn["name"]) except Exception as e: logging.error( f"Exception while deleting unused connection: {conn['name']} ({conn['oid']}) - {e}" ) print( f"Error deleting unused connection: {conn['name']} ({conn['oid']})" ) bypassed.append(conn["name"]) else: # If the connection is used, add each dependency row to CSV. # Use a fallback value if 'title' or 'oid' is missing. for dep in dependencies: dep_title = dep.get( "title", "NULL TITLE Share Datasource with associated Bearer Token user to include in CSV", ) dep_oid = dep.get("oid", "NULL OID") data.append( { "Connection Name": conn["name"], "Connection ID": conn["oid"], "Elasticube/Data Model Name": dep_title, "Elasticube/Data Model ID": dep_oid, } ) # Create a DataFrame of only the used connections (with datasource dependencies) df = pd.DataFrame(data) df.to_csv(csv_path, index=False) row_count = len(df) logging.info(f"Generated CSV at {csv_path}") print( f"CSV file generated at {csv_path}. " f"It contains {row_count} row(s) of used connections.\n" "Please review and remove lines for connections you want to keep " "before running deletion step by running tool again. Remaining lines will be deleted in deletion mode." ) # Summaries for auto-deleted (unused) connections print("\nSummary Report:") print(f"Total lines in CSV (active used connections): {row_count}") if deleted: print("\nDeleted Unused Connections:") for d in deleted: print(f" - {d}") else: print("\nNo connections were deleted in this step.") if bypassed: print("\nBypassed Connections:") for b in bypassed: print(f" - {b}") else: print("\nNo connections were bypassed in this step.") def delete_connections_from_csv(): """ Reads the CSV (remaining lines after user review, removing lines for connections to keep), then deletes each connection listed. If a delete fails or returns None, the connection is logged and added to 'bypassed'. """ if not os.path.exists(csv_path): print( "CSV file does not exist. Please generate it first or fix the config path." ) return df = pd.read_csv(csv_path) deleted = [] bypassed = [] for _, row in df.iterrows(): conn_id = row["Connection ID"] conn_name = row["Connection Name"] action_msg = "[Dry Run] Would delete" if dry_run else "Deleted" log_str = f"{action_msg} connection: {conn_name} ({conn_id})" print(log_str) logging.info(log_str) if not dry_run: try: response = delete_connection(conn_id) if response is None: logging.error( f"Failed to delete connection: {conn_name} ({conn_id})" ) print(f"Error deleting connection: {conn_name} ({conn_id})") bypassed.append(conn_name) else: deleted.append(conn_name) except Exception as e: logging.error( f"Exception while deleting connection: {conn_name} ({conn_id}) - {e}" ) print(f"Error deleting connection: {conn_name} ({conn_id})") bypassed.append(conn_name) # Print summary report print("\nSummary Report:") if deleted: print("Deleted Connections:") for d in deleted: print(f" - {d}") else: print("No connections were deleted.") if bypassed: print("\nBypassed:") for b in bypassed: print(f" - {b}") else: print("No connections were bypassed.") if __name__ == "__main__": # If there is no CSV file found, select step 1 automatically if not os.path.exists(csv_path): print( "No CSV file found; defaulting to generating connections CSV. " "Correct config if CSV file name has changed." ) generate_connections_csv() else: choice = input( "Choose an option:\n" "1 - Generate connection CSV\n" "2 - Delete connections from CSV list\n" "Enter your choice (1 or 2): " ) if choice == "1": generate_connections_csv() elif choice == "2": delete_connections_from_csv() else: print("Invalid option. Please enter '1' or '2'.") Conclusion By automating the detection of inactive connections and simplifying their removal, the Sisense Connection Prune Tool reduces clutter in the Sisense server Connection Manager UI while minimizing the risk of unintentionally impacting active datasources. Whether you opt for a dry-run mode to review potential deletions in a generated CSV file, or to simply list connections, or proceed with the full removal of unused connections, this tool offers a clear, flexible, and reliable approach to keeping your connections organized. A full copy of the tool, is attached below.        

      Jeremy Friedel
      Jeremy FriedelPosted 1 year ago • Last reply 6 months ago
      4