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
    Linux
    • 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
               
      • Strategy & Use CasesChevronRightIcon

      [Linux] Exploring the Integration of Sisense Details with SQL Queries

                       

      As of 2026.3.1, Sisense does not have a native option to inject Sisense identifiers into the Sisense-generated SQL query. The only exception are connections to the Redshift data source, which include a SQL comment with a query GUID of the Sisense query. This connector plugin looks to solve this by injecting additional information into the SQL query sent to the Live data source. Due to the structure of connector hooks, the Query GUID can not be injected into the SQL query. Instead, the plugin adds the following information: userId=6a4bb4c41grds4543d77486; dashboardId=6a79b639e543d2cdb6b005; MySQL; widgetId=6a79b696ehtfdgtr437; tableName=; userGroups=[6a4bb4c46grdsgrds3a774cabb,6a4bb4c4155436grds534d77484] Hook and Logs The plugin relies on the beforeExecuteQuery hook. For the logs, after the plugin is installed, in the query logs of your data source, you will see the following messages: [] [2026-08-26 12:57:46.726] [INFO] [ContextCommentExtension] [infra-1] [ContextCommentExtension.java:93] [X-request:] [tenant:] [[ContextCommentExtension] Attached context comment to outgoing query: userId=6a4bb4c41grds4543d77486; dashboardId=6a79b639e543d2cdb6b005; MySQL; widgetId=6a79b696ehtfdgtr437; tableName=; userGroups=[6a4bb4c46grdsgrds3a774cabb,6a4bb4c4155436grds534d77484] Steps 1. Upload the attached folder from this zip file Extract and copy the folder to the /opt/sisense/storage/data path It will be stored as /opt/sisense/storage/data/context-comment-plugin 2. Create a context-comment-plugin/lib subfolder. 3. Populate the lib with the required JARs: a. connectors-sdk-*.jar: identify the connectors SDK version and copy it: kubectl exec -it -n sisense $(kubectl get po -n sisense -l app=query -o custom-columns=":.metadata.name" | tail -n 1) -- ls -la /opt/sisense/app/lib/ | grep connectors-sdk kubectl cp -n sisense $(kubectl get po -n sisense -l app=query -o custom-columns=":.metadata.name" | tail -n 1):/opt/sisense/app/lib/connectors-sdk-1.0.8.jar /opt/sisense/storage/data/context-comment-plugin/lib/connectors-sdk-1.0.8.jar b. lombok.jar: curl -L -o /opt/sisense/storage/data/context-comment-plugin/lib/lombok.jar https://projectlombok.org/downloads/lombok.jar c. slf4j-api.jar : curl -L -o /opt/sisense/storage/data/context-comment-plugin/lib/slf4j-api.jar https://repo1.maven.org/maven2/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar 4. Make the script executable. chmod +x /opt/sisense/storage/data/context-comment-plugin/build.sh 5. Execute the shell script /opt/sisense/storage/data/context-comment-plugin/build.sh 6. List the connectors using sisense cli si connectors list If you are not able to run this, you might not have Sisense CLI installed, run the following commands (the user needs to have permission). 7. Install the plugin using, replacing the {CONNECTOR} with your data source. si connectors plugins install -connector-id {CONNECTOR_ID} -file /opt/sisense/storage/data/context-comment-plugin/dist/ContextCommentPlugin.tar.gz Some examples #Snowflake si connectors plugins install -connector-id SnowflakeJDBC -file /opt/sisense/storage/data/context-comment-plugin/dist/ContextCommentPlugin.tar.gz #PostgreSQL si connectors plugins install -connector-id PostgreSQL -file /opt/sisense/storage/data/context-comment-plugin/dist/ContextCommentPlugin.tar.gz #Databricks si connectors plugins install -connector-id Databricks -file /opt/sisense/storage/data/context-comment-plugin/dist/ContextCommentPlugin.tar.gz 8. You should be receiving Sisense details on your query logs on your database logs. NOTE: If you just refreshed your widgets, you might wait up to 10min due to cache. Caveats The plugin affects Live models only. This can be adjusted in the Java code of the plugin. The plugin is installed separately for each connector type. Some INFO log entries are added to the query service. The used JAR files were tested with a specific Sisense version (2026.3.1). You may need to adjust the JAR versions to work with a specific Sisense release. 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.

      Felipe Carneiro
      Felipe CarneiroPosted 1 week ago
      0
               
      • Widget & Dashboard ScriptsChevronRightIcon

      Copy a Pivot Cell Value to the Clipboard on Click [Linux]

                               

      Overview The Pivot 2.0 API exposes a cellClick event that fires whenever a user clicks a cell, handing to script the full cell context. This short guide uses that event to add a small convenience: click any cell and its value is copied straight to the clipboard, with a brief toast to confirm. The implementation is intentionally minimal. One event handler plus two small helpers. Availability: The Pivot 2.0 API (including cellClick) is available in Sisense L8.2.1 and later , on Linux versions, and only for Pivot 2.0 widgets.

      Step-by-step guide Open your Pivot 2.0 widget and click Edit Script (the pencil / script icon in the widget menu). Paste the script below into the editor. Click Apply , save the dashboard, and refresh. Clicking any cell now copies its value and shows a confirmation toast. widget.on("cellClick", function (widget, eventData) { const value = eventData?.metadata?.cellData?.content; if (value === undefined || value === null || value === "") { return; } if (typeof value !== "string") { console.error( "[pivot2-copy] Expected cell content to be a string, got " + typeof value + ":", value ); return; } copyToClipboard(value).then(function (ok) { if (ok) { showToast("Copied: " + value); } else { console.error("[pivot2-copy] Failed to copy cell value to clipboard"); } }); }); // --- helpers --------------------------------------------------------------- // Resolves to true only when the text actually reached the clipboard. // Prefer the async Clipboard API (requires HTTPS and, in iframe embeds, // allow="clipboard-write"); fall back to a hidden textarea otherwise. function copyToClipboard(text) { if (navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard.writeText(text).then( function () { return true; }, function () { return legacyCopy(text); } ); } return Promise.resolve(legacyCopy(text)); } function legacyCopy(text) { const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); let ok = false; try { ok = document.execCommand("copy"); } catch (e) { ok = false; } document.body.removeChild(ta); return ok; } function showToast(msg) { const id = "pivot2-copy-toast"; const existing = document.getElementById(id); if (existing) { existing.parentNode.removeChild(existing); } const el = document.createElement("div"); el.id = id; el.textContent = msg; el.style.cssText = [ "position:fixed", "bottom:24px", "left:50%", "transform:translateX(-50%)", "background:#2b2b2b", "color:#fff", "padding:8px 14px", "border-radius:6px", "font:13px/1.3 Arial, sans-serif", "box-shadow:0 2px 8px rgba(0,0,0,.3)", "z-index:99999", "opacity:0", "transition:opacity .15s ease", ].join(";"); document.body.appendChild(el); requestAnimationFrame(function () { el.style.opacity = "1"; }); setTimeout(function () { el.style.opacity = "0"; setTimeout(function () { if (el.parentNode) { el.parentNode.removeChild(el); } }, 200); }, 1400); } How it works Widget.on("cellClick", ...) fires on every cell click and passes eventData. The displayed text lives at eventData.metadata.cellData.content — that's what we copy, so it works for both value cells (the number) and member cells (the label). Empty cells (blank grand-total corners, etc.) are ignored, and a quick type check makes sure cell content is a string, as it should be. Toast is a self-removing element fixed to the bottom of the screen confirms the copy, then fades out after ~1.4s (can be adjusted). Re-clicking replaces any existing toast so they don't stack up. A note on the Clipboard API The copy uses the modern asynchronous Clipboard API (navigator.clipboard.writeText), which is the reliable path, but it has two requirements : Secure context (HTTPS) - on plain HTTP the Clipboard API is unavailable. Embedded dashboards (iframes) need allow="clipboard-write" on the iframe, or the write is blocked by permissions policy. When the Clipboard API is missing or blocked, the script falls back to the legacy approach — a hidden <textarea> plus document.execCommand("copy"). This works in more contexts (including some older embedded browsers) but is deprecated and can fail silently in others. Because both paths return their real success/failure, the toast only shows "Copied:" when the value genuinely reached the clipboard. If both paths fail, nothing is copied and a message is logged to the browser console ([pivot2-copy] Failed to copy...) rather than misleading the user with a false confirmation. Notes and limitations Pivot 2.0 / Linux / L8.2.1+ only, as noted above. The field path metadata.cellData.content reflects the current event payload; if a future version changes the shape, it should be adjusted. This copies the displayed content (formatted text), not the underlying raw numeric value. To copy the raw value target eventData.metadata.cellData.value Resources Pivot 2.0 API — cellClick event and the cell context payload: https://developer.sisense.com/guides/customJs/jsApiRef/widgetClass/pivot2.html#cellclick Widget class: https://developer.sisense.com/guides/customJs/jsApiRef/widgetClass/#widget-class

      Ivan Amoshyi
      Ivan AmoshyiPosted 1 month ago
      0
               
    • Blog banner
      • PySisenseChevronRightIcon

      Bulk Updating BloX Widget Fonts with PySisense

                                                                                                       

      Bulk Updating BloX Widget Fonts with PySisense Overview BloX widgets do not inherit the font configured in a Sisense instance's Look and Feel settings. Changing the tenant level font has no effect on existing BloX widgets, and updating each one by hand through the BloX template editor becomes impractical once a dashboard has more than a few of these widgets, or the same change needs to be applied across many dashboards. This article describes blox_font_updater.py , a Python command-line tool that finds BloX widgets across a Sisense tenant and updates their font using the pySisense library. The tool changes only the font of BloX widgets. It does not modify any other widget type, and it does not change the tenant level Look and Feel font; it edits each BloX widget's own style or font configuration directly. Full coverage is not guaranteed on every widget: BloX widgets are built from open ended custom HTML and JSON, so a small number may still need a manual adjustment after a run. The full source is available on GitHub in this repository . Two ways to change a BloX widget's font A BloX widget's font can be set in two different places, and the tool can use either one, or both: CSS rule : adds a CSS rule to the front of the widget's existing custom style. A wildcard selector targets nearly every element in the widget, including elements such as input fields that do not otherwise inherit a font from their surroundings. Font configuration : sets Sisense's own font setting for the widget directly. This is closer to how a BloX widget's font is normally configured, but depends on the widget's own template reading that setting, so it can miss some elements. Both mechanisms are applied by default. A widget that is already correct for one mechanism is left unchanged for that mechanism even when both are in use, so a run only touches whatever is actually out of date. About PySisense PySisense is a Python SDK for the Sisense REST API, installed via pip. It provides wrappers around common Sisense operations, including dashboard and widget access, and handles authentication, session management, and request errors internally. This tool uses pySisense's SisenseClient and Dashboard classes to discover dashboards and widgets, and to read and write BloX widget definitions. Prerequisites Python 3.13 or later. pySisense and PyYAML, installed with pip install -r requirements.txt . A Sisense API token, ideally belonging to a Sisense administrator. An administrator token lets the tool discover and update BloX widgets across the whole tenant, rather than only dashboards owned by or shared with one user. Setup Copy the example configuration and fill in the Sisense domain and API token: cp config.example.yaml config.yaml The tool is ready to run once domain and token are filled in. The remaining settings control the font change itself and are covered in the next section. Configuration reference # Copy this file to config.yaml and fill in real values: # # cp config.example.yaml config.yaml # # config.yaml contains a real credential once filled in - never send it to # anyone else or commit it to source control. When sharing this tool with # someone else, share this file (config.example.yaml), not a filled-in # config.yaml. # The Sisense server's address. A "http://" or "https://" prefix is fine # here if included - it's ignored either way, since is_ssl below is what # actually decides whether HTTPS or HTTP is used. domain: "your-tenant.sisense.com" # Leave this as true unless the Sisense server uses plain HTTP. is_ssl: true # A Sisense API token. Recommended: a Sisense administrator's token - see # is_user_admin below, and the README's "Getting a Sisense API token" and # "Permissions" sections. token: "YOUR_SISENSE_API_TOKEN" # The kind of widget this tool looks for. Leave this as BloX. widget_type: BloX # Recommended: true, with an administrator token (see token above) - finds # widgets across every dashboard in the tenant, not just ones owned by or # shared with one particular user, and an administrator is also the most # likely to already have the access needed to update most of them. # Set to false to only look at dashboards the API user can already see. is_user_admin: true # Even with is_user_admin above, Sisense can still deny a widget update with # "access denied" if the API user has no owner or edit share on that specific # dashboard - admin access relaxes what can be read, not what can be written. # Set this to true to have the tool work around that automatically: for a # widget that fails this way, it temporarily makes the API user the # dashboard's owner, applies the change, then restores the original owner and # shares. Leave this as false unless that "access denied" failure is actually # being seen - see the README's "Permissions" section for the tradeoffs. use_temporary_ownership: false # The settings below control what the tool does by default when run # without the matching command-line option. Anything here can still be # overridden per run - for example, --font "Georgia, serif" always wins over # default_font_family below, no matter what it's set to. # The CSS selector used when default_font_mode is css or both (see README.md). default_selector: ".blox-slides *" # The font used for the change - a full CSS font stack (e.g. with fallback # fonts). Used as-is for default_font_mode: css. For default_font_mode: # config, only the first name is used (e.g. "Arial" here) - that's the # typical, well-supported value for Sisense's font config field, even though # it has also been observed to accept a full stack like this one directly. default_font_family: "Arial, Helvetica, sans-serif" # One of: css, config, both - see "Two ways to change the font" in # blox_font_updater.py, or the README, for what each one does. "both" is # the default here for the widest out-of-the-box coverage. default_font_mode: both # Maximum number of widgets a single run will touch. 0 (or a negative # number) means no limit. default_max_widgets: 10 domain , is_ssl , token : connection details for the Sisense instance. A http:// or https:// prefix on domain is ignored either way; is_ssl decides the scheme. widget_type : left as BloX . is_user_admin : true to discover widgets across the whole tenant with an administrator token, false to only look at dashboards the token's own user can already see. default_selector : the CSS selector used when default_font_mode is css or both . default_font_family : the font applied. A full CSS font stack is used as is for the CSS rule; only the first name is used for the font configuration field. default_font_mode : css , config , or both , as described above. default_max_widgets : how many widgets a single run touches. 0 or a negative number removes the limit. use_temporary_ownership : false by default. Set to true to have the tool temporarily take ownership of a dashboard when a widget update is denied with "access denied," even with is_user_admin set. Each of these can be overridden for a single run with a matching command-line option ( --selector , --font , --font-mode , --max-widgets ) without changing config.yaml . Usage Run with no options to preview which widgets would change, without writing anything: python blox_font_updater.py Apply the change to a single widget first, then confirm it in Sisense: python blox_font_updater.py --apply --max-widgets 1 If the result is not correct, every change made by the tool can be reverted: python blox_font_updater.py --undo Once confirmed, apply the change across the tenant: python blox_font_updater.py --apply --all Common use cases Rolling out a font change across a tenant Set default_selector , default_font_family , and default_font_mode in config.yaml to the desired values, then run the sequence in Usage: preview, apply to one widget, confirm, apply to all. Using a different font for a single run, without changing config.yaml python blox_font_updater.py --apply --font "Georgia, serif" --font-mode config Reducing log output on a large run python blox_font_updater.py --apply --all --quiet Apply Change to One Specific Widget ID Only python blox_font_updater.py --apply --dashboard-id <dashboardId> --widget-id <widgetId> Logs and backups Every run writes to logs/blox_font_update.log . Before any widget is changed, its complete prior definition is recorded in logs/blox_font_backup.json . --undo reads that file and restores every change that has not already been undone, most recent first. Both paths can be changed with --log-file and --backup-file . Full option reference usage: blox_font_updater.py [-h] [--config PATH] [--selector CSS_SELECTOR] [--font FONT_FAMILY] [--font-mode {css,config,both}] [--max-widgets N] [--all] [--dashboard-id ID] [--widget-id ID] [--apply] [--force] [--undo] [--backup-file PATH] [--log-file PATH] [--quiet] [--verbose] Change the font used by BloX widgets on a Sisense instance. options: -h, --help show this help message and exit --config PATH Path to config.yaml. --selector CSS_SELECTOR Override default_selector from config.yaml, for this run only. Normally set in config.yaml instead. Used if neither is set: '.blox-slides *'. --font FONT_FAMILY Override default_font_family from config.yaml, for this run only. Normally set in config.yaml instead. Used if neither is set: 'Arial, Helvetica, sans- serif'. --font-mode {css,config,both} Override default_font_mode from config.yaml, for this run only - normally set in config.yaml instead. Used if neither is set: 'both'. 'css' adds a CSS rule that directly targets nearly every element, including custom HTML and input fields. 'config' sets Sisense's simpler built-in font setting instead, which some widgets' own elements (including input fields) may not pick up. Neither is guaranteed to cover everything - BloX templates vary - so 'both' applies both changes for the best chance of full coverage. --max-widgets N Override default_max_widgets from config.yaml, for this run only. Normally set in config.yaml instead. Used if neither is set: 10. 0 or a negative number means no limit - same as --all. --all Process every matching widget, with no limit. Overrides --max-widgets. --dashboard-id ID Target one already-known widget directly by dashboard and widget id, instead of scanning the tenant. Must be used together with --widget-id. --max-widgets/--all are ignored in this mode. --widget-id ID Used together with --dashboard-id. See --dashboard-id. --apply Actually write changes to Sisense. Without this flag, the tool only previews what it would do. --force Re-apply even to widgets that already look up to date for the selected --font-mode. --undo Revert every not-yet-undone change recorded in --backup-file, then exit. --backup-file PATH Where change backups are stored. --log-file PATH Where the run log is written. --quiet Only log the scan summary and any warnings/errors - skip the per-widget lines. Useful for large runs. --verbose Show full style strings in the log instead of a truncated preview. Use case Using blox_font_updater.py instead of manual BloX template edits offers several advantages: Applies a font change consistently across every BloX widget in a tenant, without opening each widget individually. Provides automatic rollback through the backup file, without a separate export or snapshot step. Supports two independent font change mechanisms, so a widget that does not respond to one still receives the font through the other. Reduces the time needed to test a change safely, through a preview mode and a single widget run before a full rollout. Summary blox_font_updater.py gives Sisense administrators a repeatable way to change the font of BloX widgets across a tenant from the command line, with a preview step, a backup of every change, and full reversal through --undo . config.yaml serves as the source of truth for the selector, font, and mode used, and can be adjusted per environment without changing the script itself. The full source is available on GitHub and can be modified as needed. """ bloxFontUpdater - bulk-update the font used by Sisense BloX widgets. BloX widgets don't inherit Sisense's tenant-level "Look and Feel" font, so this tool sets one directly, across every BloX widget it finds, by adding a CSS rule, by setting Sisense's own font config field, or both (see resolveSetting/applyChanges below and "Two ways to change the font" in README.md). Preview-only by default; every change is backed up and reversible with --undo. Full documentation, including configuration and the two font-change mechanisms: README.md Command-line options: --help python blox_font_updater.py # preview only, changes nothing python blox_font_updater.py --apply --max-widgets 1 python blox_font_updater.py --undo """ import argparse import base64 import copy import json import logging import os import sys from datetime import datetime, timezone _installHint = ( "Missing dependency: {module}.\n\n" "Install the required packages first, for example:\n" " pip install -r requirements.txt\n" "or, with uv:\n" " uv sync\n" ) try: import yaml except ImportError: sys.exit(_installHint.format(module="PyYAML")) try: from pysisense import SisenseClient, Dashboard except ImportError: sys.exit(_installHint.format(module="pysisense")) # --------------------------------------------------------------------------- # Fallback values, used only for a setting that is given on neither the # command line nor in config.yaml. Most users should set default_selector, # default_font_family, default_font_mode, and default_max_widgets in # config.yaml instead of changing these - see config.example.yaml. # --------------------------------------------------------------------------- fallbackSelector = ".blox-slides *" fallbackFontFamily = "Arial, Helvetica, sans-serif" # "both" gives the widest out-of-the-box coverage, so it's the fallback - # see "Two ways to change the font" in README.md. fallbackFontMode = "both" # Safety cap on how many widgets a single run will touch. Set # default_max_widgets to 0 in config.yaml (or pass --all) to remove the cap. fallbackMaxWidgets = 10 # The only valid values for config.yaml's default_font_mode (or --font-mode). fontModeChoices = ("css", "config", "both") scriptDir = os.path.dirname(os.path.abspath(__file__)) defaultConfigPath = os.path.join(scriptDir, "config.yaml") defaultLogFile = os.path.join(scriptDir, "logs", "blox_font_update.log") defaultBackupFile = os.path.join(scriptDir, "logs", "blox_font_backup.json") # Length a style preview is truncated to in normal (non --verbose) logging, # so a widget with a very long style string doesn't flood the terminal. stylePreviewLength = 100 # Fields Sisense manages internally and does not expect to receive back when # saving a widget - present on data read from the API, but absent from a # widget definition when it is written. serverManagedWidgetFields = ( "oid", "_id", "owner", "userId", "created", "lastUpdated", "instanceType", "dashboardid", ) def setupLogger(logFile): """Log to both the console and logFile, so a run can be reviewed afterwards.""" # dirname is "" for a bare filename (e.g. --log-file run.log, meaning # "current directory") - makedirs("") raises, so only call it when # there's an actual directory to create. logDir = os.path.dirname(logFile) if logDir: os.makedirs(logDir, exist_ok=True) logger = logging.getLogger("bloxFontUpdater") logger.setLevel(logging.INFO) logger.handlers.clear() # avoid duplicate output if setupLogger is ever called twice formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") fileHandler = logging.FileHandler(logFile) fileHandler.setFormatter(formatter) logger.addHandler(fileHandler) consoleHandler = logging.StreamHandler() consoleHandler.setFormatter(formatter) logger.addHandler(consoleHandler) return logger def previewStyle(styleText, verbose): """Shorten a style string for logging, unless --verbose was requested.""" if verbose or len(styleText) <= stylePreviewLength: return styleText return styleText[:stylePreviewLength] + f"... ({len(styleText)} chars total, use --verbose to see all)" def buildCssRule(selector, fontFamily): """Build the single CSS rule that gets added to the front of a widget's style.""" return f"{selector}{{font-family: {fontFamily} !important}}" def extractPrimaryFontName(fontFamily): """ Turn a CSS font-family value into just its first font name, e.g. "Arial, Helvetica, sans-serif" -> "Arial", or '"Open Sans", sans-serif' -> "Open Sans". Used for Sisense's font config field (style.currentConfig. fontFamily), which is normally set to a single font name like this - that field has also been observed to accept a full comma-separated stack, but a single name is the more typical, well-supported value for it. """ firstFont = fontFamily.split(",")[0].strip() return firstFont.strip("'\"") def mergeStyle(existingStyle, newRule): """ Add newRule to the front of a widget's existing style, without altering the existing content in any way. A BloX widget's style string may be missing entirely, empty, a single rule, several rules concatenated together, or CSS left over from manual editing that doesn't fully make sense on its own. In every case, newRule is placed at the front and whatever was already there is kept exactly as it was - this tool never tries to interpret or "clean up" existing CSS. """ existingStyle = existingStyle or "" if not existingStyle.strip(): return newRule return f"{newRule} {existingStyle}" def ruleAlreadyApplied(existingStyle, newRule): """True if newRule is already the first thing in the style string.""" return (existingStyle or "").strip().startswith(newRule) def loadBackup(path): """Read the JSON list of previously-applied changes, or [] if none exist yet.""" if not os.path.exists(path): return [] with open(path, "r") as backupFile: try: return json.load(backupFile) except json.JSONDecodeError: return [] def saveBackup(path, records): """Persist the backup list. Called immediately after every change, so an interrupted run never loses track of widgets it already modified.""" # See setupLogger for why this only calls makedirs when there's an # actual directory component. backupDir = os.path.dirname(path) if backupDir: os.makedirs(backupDir, exist_ok=True) with open(path, "w") as backupFile: json.dump(records, backupFile, indent=2) def getWidgetEndpoint(dashboardId, widgetId, isAdmin): """Endpoint used to read a single widget's full definition.""" endpoint = f"/api/v1/dashboards/{dashboardId}/widgets/{widgetId}" if isAdmin: endpoint += "?adminAccess=true" return endpoint def putWidgetEndpoint(dashboardId, widgetId): """Endpoint used to save a widget - the same one the Sisense web UI itself uses when a widget is edited and saved.""" return f"/api/dashboards/{dashboardId}/widgets/{widgetId}" def buildPutPayload(widget): """Strip the server-managed fields Sisense does not expect back when a widget is saved (see serverManagedWidgetFields).""" return {key: value for key, value in widget.items() if key not in serverManagedWidgetFields} def describeErrorResponse(response): """ Best-effort description of a failed request, for logging. `requests.Response` objects are falsy for any non-2xx status - `if response` is True only when the request actually succeeded, not merely when a response was received. Checking `response is not None` here is what actually distinguishes "no response at all" (e.g. a network error) from "the server responded with an error", so the real error detail (e.g. "Widget not found") gets logged instead of a misleading "No response received." Also falls back gracefully if the error body isn't valid JSON. """ if response is None: return "No response received." try: return response.json() except ValueError: return response.text or f"HTTP {response.status_code}" def decodeJwtUserId(token): """ Read the "user" claim out of a Sisense API token, without verifying its signature - used only to learn the API user's own id for use_temporary_ownership, not for any security decision. Sisense API tokens are JWTs: three dot-separated, base64url-encoded segments (header, payload, signature). The payload segment decodes to a JSON object containing, among other things, "user" (the token owner's own id). Returns None if token isn't a decodable JWT of this shape. """ try: payloadSegment = token.split(".")[1] padded = payloadSegment + "=" * (-len(payloadSegment) % 4) payload = json.loads(base64.urlsafe_b64decode(padded)) except (IndexError, ValueError, TypeError): return None return payload.get("user") def getDashboardOwnerId(apiClient, dashboardId, logger): """Look up the current owner of a dashboard, for use_temporary_ownership - the same admin endpoint pysisense's own get_all_dashboards() reads from, scoped to one dashboard.""" response = apiClient.get(f"/api/v1/dashboards/admin?dashboardType=owner&id={dashboardId}&asObject=false") if not response or not response.ok: logger.error(f"Could not look up the current owner of dashboard {dashboardId}: {describeErrorResponse(response)}") return None matches = response.json() if not matches: logger.error(f"Could not look up the current owner of dashboard {dashboardId}: dashboard not found.") return None return matches[0].get("owner") def getDashboardShares(apiClient, dashboardId, logger): """Read a dashboard's current share list, so use_temporary_ownership can restore it exactly after a temporary ownership change.""" response = apiClient.get(f"/api/shares/dashboard/{dashboardId}?adminAccess=true") if not response or not response.ok: logger.error(f"Could not read the current shares for dashboard {dashboardId}: {describeErrorResponse(response)}") return None return response.json().get("sharesTo", []) def changeDashboardOwner(apiClient, dashboardId, newOwnerId, useAdminAccess, logger): """ Change who owns a dashboard - the same endpoint pysisense's own add_widget_script() helper uses to temporarily take ownership of a dashboard it doesn't otherwise have edit rights on. useAdminAccess should be True when taking ownership (the API user typically isn't already a party to the dashboard) and False when handing it back (the API user is the current owner at that point, so no admin override is needed). """ endpoint = f"/api/v1/dashboards/{dashboardId}/change_owner" if useAdminAccess: endpoint += "?adminAccess=true" response = apiClient.post(endpoint, data={"ownerId": newOwnerId, "originalOwnerRule": "edit"}) if not response or not response.ok: logger.error(f"Could not change the owner of dashboard {dashboardId} to {newOwnerId}: {describeErrorResponse(response)}") return False return True def restoreDashboardShares(apiClient, dashboardId, shares, logger): """Write a previously-captured share list (from getDashboardShares) back onto a dashboard, after a temporary ownership change.""" payload = { "sharesTo": [ { "shareId": share["shareId"], "type": share["type"], "rule": share.get("rule", "edit"), "subscribe": share.get("subscribe", False), } for share in shares ] } response = apiClient.post(f"/api/shares/dashboard/{dashboardId}", data=payload) if not response or not response.ok: logger.error(f"Could not restore the original shares for dashboard {dashboardId}: {describeErrorResponse(response)}") return False return True def withTemporaryOwnership(apiClient, dashboardId, apiUserId, logger, action): """ Make apiUserId the temporary owner of dashboardId, run action(), then restore the original owner and shares - even if action() raises, or the restore step itself fails partway. This is the use_temporary_ownership fallback (see config.example.yaml and README.md "Permissions"): Sisense can deny a widget write with 403 even to an admin token, when the token's user has no owner/edit share on that specific dashboard. Mirrors the ownership-swap-and-restore pattern pysisense's own add_widget_script() uses for widget scripts, applied here to widget field writes instead. Unlike that pysisense helper, restoration here always runs - including when action() raises or the write itself fails - so a dashboard is never left owned by the tool's API user because of a mid-run error. Returns (result, attempted): result is action()'s return value, or None if the swap could not be attempted at all (owner/shares lookup failed, or the ownership change itself failed - nothing was touched in that case). attempted is True whenever action() actually ran. """ originalOwnerId = getDashboardOwnerId(apiClient, dashboardId, logger) if not originalOwnerId: return None, False if originalOwnerId == apiUserId: # Already the owner - a plain write should not have failed with 403 # for ownership reasons, so there's nothing useful to swap here. return None, False originalShares = getDashboardShares(apiClient, dashboardId, logger) if originalShares is None: return None, False if not changeDashboardOwner(apiClient, dashboardId, apiUserId, True, logger): return None, False logger.warning(f"Temporarily changed the owner of dashboard {dashboardId} to the API user, to apply the font change.") try: result = action() finally: sharesRestored = restoreDashboardShares(apiClient, dashboardId, originalShares, logger) ownerRestored = changeDashboardOwner(apiClient, dashboardId, originalOwnerId, False, logger) if sharesRestored and ownerRestored: logger.warning(f"Restored the original owner and shares for dashboard {dashboardId}.") else: logger.error( f"Dashboard {dashboardId} may still be owned by the API user - restoring shares " f"{'succeeded' if sharesRestored else 'FAILED'} and restoring ownership " f"{'succeeded' if ownerRestored else 'FAILED'}. Check this dashboard's owner and " "shares in Sisense directly." ) return result, True def putWidgetWithOwnershipFallback(apiClient, dashboardId, widgetId, payload, useTemporaryOwnership, apiUserId, logger): """ Save a widget, retrying once through withTemporaryOwnership if the first attempt is denied with 403 and use_temporary_ownership is enabled. Returns (response, usedTemporaryOwnership) - response is whichever attempt actually produced one (None if the fallback couldn't even be attempted). """ response = apiClient.put(putWidgetEndpoint(dashboardId, widgetId), data=payload) deniedByPermissions = response is not None and response.status_code == 403 if not (deniedByPermissions and useTemporaryOwnership and apiUserId): return response, False logger.warning( f"Widget {widgetId} on dashboard {dashboardId} was denied (403) - retrying with " "temporary dashboard ownership since use_temporary_ownership is enabled." ) fallbackResponse, attempted = withTemporaryOwnership( apiClient, dashboardId, apiUserId, logger, lambda: apiClient.put(putWidgetEndpoint(dashboardId, widgetId), data=payload), ) if not attempted: # Swap couldn't even be attempted - report the original 403, not "no response". return response, False return fallbackResponse, True def fetchWidget(apiClient, dashboardId, widgetId, isAdmin, logger): """Fetch a widget's full current definition - the source of truth this tool reads from, diffs against, and eventually writes back to.""" response = apiClient.get(getWidgetEndpoint(dashboardId, widgetId, isAdmin)) if not response or not response.ok: logger.error(f"Could not fetch widget {widgetId} on dashboard {dashboardId} - skipping it. {describeErrorResponse(response)}") return None return response.json() def findTargetWidgets(apiClient, dashboards, widgetType, isAdmin, maxWidgets, logger): """ Scan dashboards for widgets matching widgetType, returning up to maxWidgets matches as a list of dicts with dashboard/widget IDs and titles. Pass maxWidgets=None to scan every dashboard with no cap. Stops scanning as soon as maxWidgets is reached, so a small run does not pay the cost of scanning every dashboard in the tenant. """ found = [] for dashboard in dashboards: if maxWidgets is not None and len(found) >= maxWidgets: break if isAdmin: # Admin export: works for dashboards not shared with this API user. response = apiClient.get( f"/api/v1/dashboards/export?dashboardIds={dashboard['oid']}&adminAccess=true" ) if not response or not response.ok: logger.warning( f"Could not export dashboard {dashboard['oid']} ({dashboard.get('title', '')}) - " f"skipping. {describeErrorResponse(response)}" ) continue exportedDashboards = response.json() exported = exportedDashboards[0] if exportedDashboards else {} candidateWidgets = exported.get("widgets") or [] else: # Only dashboards this API user can already see. response = apiClient.get(f"/api/v1/dashboards/{dashboard['oid']}/widgets") if not response or not response.ok: logger.warning( f"Could not fetch widgets for dashboard {dashboard['oid']} ({dashboard.get('title', '')}) - " f"skipping. {describeErrorResponse(response)}" ) continue candidateWidgets = response.json() or [] for widget in candidateWidgets: if widget.get("type") != widgetType: continue found.append( { "dashboardId": dashboard["oid"], "dashboardTitle": dashboard.get("title", ""), "widgetId": widget["oid"], "widgetTitle": widget.get("title", ""), } ) if maxWidgets is not None and len(found) >= maxWidgets: break return found def undoChanges(apiClient, backupFile, logger, useTemporaryOwnership=False, apiUserId=None): """Restore every not-yet-undone widget in backupFile to its recorded state.""" records = loadBackup(backupFile) pending = [record for record in records if not record.get("undone")] if not pending: logger.info("Nothing to undo - no pending changes found in the backup file.") return logger.info(f"Reverting {len(pending)} previously applied change(s)...") # Revert most-recent-first, like a normal undo stack. for record in reversed(records): if record.get("undone"): continue # A hand-edited or corrupted backup file shouldn't be able to abort # the whole run - skip just this record and keep going. missingKeys = [key for key in ("dashboardId", "widgetId", "originalWidget") if key not in record] if missingKeys: logger.error(f"Skipping a backup record missing {', '.join(missingKeys)} - it cannot be undone.") continue dashboardId = record["dashboardId"] widgetId = record["widgetId"] originalWidget = record["originalWidget"] response, _ = putWidgetWithOwnershipFallback( apiClient, dashboardId, widgetId, buildPutPayload(originalWidget), useTemporaryOwnership, apiUserId, logger, ) if response and response.ok: record["undone"] = True record["undoneAt"] = datetime.now(timezone.utc).isoformat() logger.info(f"Reverted widget {widgetId} ({record.get('widgetTitle', '')}) on dashboard {dashboardId}.") else: logger.error(f"Failed to revert widget {widgetId}: {describeErrorResponse(response)}") # Save after every widget, not just at the end, so a partial undo run isn't lost. saveBackup(backupFile, records) logger.info("Undo complete.") def applyChanges( apiClient, candidates, selector, fontFamily, fontMode, apply, force, backupFile, isAdmin, quiet, verbose, logger, useTemporaryOwnership=False, apiUserId=None, ): """ Apply the configured font change(s) to each candidate widget. fontMode controls which of the two font-change mechanisms are used (see "Two ways to change the font" in README.md): - "css": add newRule to the front of style.currentCard.style - "config": set style.currentConfig.fontFamily to a single font name - "both": do both A widget is skipped only if every mechanism in use is already up to date for it; otherwise, only the mechanism(s) that actually need a change are applied (so, in "both" mode, a widget that already has the right CSS rule but the wrong font config only gets its font config updated). """ updateCss = fontMode in ("css", "both") updateConfig = fontMode in ("config", "both") newRule = buildCssRule(selector, fontFamily) if updateCss else None primaryFontName = extractPrimaryFontName(fontFamily) if updateConfig else None if updateCss: logger.info(f"CSS rule to apply: {newRule}") if updateConfig: logger.info(f"Font config value to apply (style.currentConfig.fontFamily): {primaryFontName!r}") backupRecords = loadBackup(backupFile) appliedCount = 0 skippedCount = 0 for candidate in candidates: # Re-fetch fresh rather than reuse the discovery payload, which has a # different shape (bulk export vs. single-widget document) and may # be stale. widget = fetchWidget(apiClient, candidate["dashboardId"], candidate["widgetId"], isAdmin, logger) if widget is None: continue # Read state for both mechanisms up front, even if only one is in # use, so the checks below don't need to branch on fontMode again. styleBlock = widget.get("style", {}) or {} currentCard = styleBlock.get("currentCard", {}) or {} currentConfig = styleBlock.get("currentConfig", {}) or {} existingStyle = currentCard.get("style", "") existingFontFamily = currentConfig.get("fontFamily", "") # A mechanism needs updating only if it's in use and not already # correct (or --force is set). This is what lets "both" mode touch # only the mechanism that's actually out of date on a given widget. needsCssChange = updateCss and (force or not ruleAlreadyApplied(existingStyle, newRule)) needsConfigChange = updateConfig and (force or existingFontFamily != primaryFontName) if not needsCssChange and not needsConfigChange: if not quiet: logger.info( f"Skipping widget {candidate['widgetId']} ({candidate['widgetTitle']!r}) - " "already up to date. Use --force to re-apply anyway." ) skippedCount += 1 continue updatedStyle = mergeStyle(existingStyle, newRule) if needsCssChange else existingStyle # Dry run: log the preview, touch nothing. if not apply: if not quiet: previewLines = [ f"[DRY RUN] Would update widget {candidate['widgetId']} " f"({candidate['widgetTitle']!r} on dashboard {candidate['dashboardTitle']!r}):" ] if needsCssChange: previewLines.append(f" CSS style - old: {previewStyle(existingStyle, verbose)!r}") previewLines.append(f" CSS style - new: {previewStyle(updatedStyle, verbose)!r}") if needsConfigChange: previewLines.append(f" Font config - old: {existingFontFamily!r}") previewLines.append(f" Font config - new: {primaryFontName!r}") logger.info("\n".join(previewLines)) continue # Back up before mutating, and save immediately so --undo survives an # interrupted run. deepcopy is required, not optional: `widget` is # mutated in place below, and saveBackup() re-serializes this whole # list on every call - a bare reference here would let a later # widget's save silently overwrite this "before" snapshot with # whatever `widget` looks like by then. backupRecords.append( { "timestamp": datetime.now(timezone.utc).isoformat(), "dashboardId": candidate["dashboardId"], "widgetId": candidate["widgetId"], "widgetTitle": candidate["widgetTitle"], "fontModeUsed": fontMode, "selectorUsed": selector if needsCssChange else None, "fontUsed": fontFamily, "originalWidget": copy.deepcopy(widget), "undone": False, } ) saveBackup(backupFile, backupRecords) # Mutate only what's out of date - a widget already correct for one # mechanism keeps that field untouched, even in "both" mode. if needsCssChange: widget.setdefault("style", {}).setdefault("currentCard", {})["style"] = updatedStyle if needsConfigChange: widget.setdefault("style", {}).setdefault("currentConfig", {})["fontFamily"] = primaryFontName response, usedTemporaryOwnership = putWidgetWithOwnershipFallback( apiClient, candidate["dashboardId"], candidate["widgetId"], buildPutPayload(widget), useTemporaryOwnership, apiUserId, logger, ) if response and response.ok: appliedCount += 1 if usedTemporaryOwnership: backupRecords[-1]["usedTemporaryOwnership"] = True saveBackup(backupFile, backupRecords) if not quiet: # Report exactly which mechanism(s) were touched for this # widget, since in "both" mode it may only have been one. changedParts = [ label for label, changed in (("CSS style", needsCssChange), ("font config", needsConfigChange)) if changed ] viaOwnershipSwap = " (via temporary dashboard ownership)" if usedTemporaryOwnership else "" logger.info( f"Updated widget {candidate['widgetId']} ({candidate['widgetTitle']!r}) " f"on dashboard {candidate['dashboardId']} - changed: {', '.join(changedParts)}{viaOwnershipSwap}." ) else: logger.error(f"Failed to update widget {candidate['widgetId']}: {describeErrorResponse(response)}") backupRecords[-1]["updateFailed"] = True saveBackup(backupFile, backupRecords) return appliedCount, skippedCount def parseArgs(): """Define and parse the tool's command-line flags.""" parser = argparse.ArgumentParser( description="Change the font used by BloX widgets on a Sisense instance.", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("--config", dest="configPath", default=defaultConfigPath, metavar="PATH", help="Path to config.yaml.") # Font settings default to None, not a real value, so main() can tell # whether each was actually passed - see resolveSetting(). parser.add_argument( "--selector", dest="selector", default=None, metavar="CSS_SELECTOR", help="Override default_selector from config.yaml, for this run only. Normally set " f"in config.yaml instead. Used if neither is set: {fallbackSelector!r}.", ) parser.add_argument( "--font", dest="fontFamily", default=None, metavar="FONT_FAMILY", help="Override default_font_family from config.yaml, for this run only. Normally set " f"in config.yaml instead. Used if neither is set: {fallbackFontFamily!r}.", ) parser.add_argument( "--font-mode", dest="fontMode", choices=fontModeChoices, default=None, help=( "Override default_font_mode from config.yaml, for this run only - normally set " f"in config.yaml instead. Used if neither is set: {fallbackFontMode!r}. " "'css' adds a CSS rule that directly targets nearly every element, including custom " "HTML and input fields. 'config' sets Sisense's simpler built-in font setting instead, " "which some widgets' own elements (including input fields) may not pick up. Neither is " "guaranteed to cover everything - BloX templates vary - so 'both' applies both changes " "for the best chance of full coverage." ), ) parser.add_argument( "--max-widgets", dest="maxWidgets", type=int, default=None, metavar="N", help="Override default_max_widgets from config.yaml, for this run only. Normally set " f"in config.yaml instead. Used if neither is set: {fallbackMaxWidgets}. " "0 or a negative number means no limit - same as --all.", ) parser.add_argument( "--all", dest="allWidgets", action="store_true", help="Process every matching widget, with no limit. Overrides --max-widgets.", ) parser.add_argument( "--dashboard-id", dest="dashboardId", default=None, metavar="ID", help="Target one already-known widget directly by dashboard and widget id, instead of " "scanning the tenant. Must be used together with --widget-id. --max-widgets/--all are " "ignored in this mode.", ) parser.add_argument( "--widget-id", dest="widgetId", default=None, metavar="ID", help="Used together with --dashboard-id. See --dashboard-id.", ) # Run control - what this particular invocation actually does. parser.add_argument( "--apply", dest="apply", action="store_true", help="Actually write changes to Sisense. Without this flag, the tool only previews what it would do.", ) parser.add_argument( "--force", dest="force", action="store_true", help="Re-apply even to widgets that already look up to date for the selected --font-mode.", ) parser.add_argument( "--undo", dest="undo", action="store_true", help="Revert every not-yet-undone change recorded in --backup-file, then exit.", ) # Where state is read from / written to. parser.add_argument("--backup-file", dest="backupFile", default=defaultBackupFile, metavar="PATH", help="Where change backups are stored.") parser.add_argument("--log-file", dest="logFile", default=defaultLogFile, metavar="PATH", help="Where the run log is written.") # Output verbosity. parser.add_argument( "--quiet", dest="quiet", action="store_true", help="Only log the scan summary and any warnings/errors - skip the per-widget lines. " "Useful for large runs.", ) parser.add_argument( "--verbose", dest="verbose", action="store_true", help="Show full style strings in the log instead of a truncated preview.", ) return parser.parse_args() def loadConfig(configPath): """Read and sanity-check config.yaml, exiting with a plain-language explanation (not a Python traceback) for the mistakes a new user is most likely to make: no config file yet, broken YAML, or placeholder values left unfilled.""" if not os.path.exists(configPath): sys.exit( f"Could not find {configPath}.\n\n" "On first use, copy the example config and fill in the required values:\n\n" " cp config.example.yaml config.yaml\n\n" "Then edit config.yaml with the Sisense domain and API token to use - see README.md." ) if not os.path.isfile(configPath): sys.exit(f"{configPath} is not a file (is it a directory?). Point --config at a config.yaml file.") with open(configPath, "r") as configFile: try: config = yaml.safe_load(configFile) or {} except yaml.YAMLError as error: sys.exit(f"{configPath} is not valid YAML: {error}\n\nCheck for typos, especially around quotes and indentation.") if not isinstance(config, dict): sys.exit(f"{configPath} must contain key: value settings, not a {type(config).__name__}. See config.example.yaml.") placeholderValues = {"", "your-tenant.sisense.com", "YOUR_SISENSE_API_TOKEN"} missingKeys = [key for key in ("domain", "token") if str(config.get(key, "")).strip() in placeholderValues] if missingKeys: sys.exit( f"{configPath} still has placeholder value(s) for: {', '.join(missingKeys)}.\n\n" "Edit config.yaml with the real Sisense domain and API token to use - see README.md." ) return config def resolveSetting(cliValue, config, configKey, fallback): """ Decide the effective value for a setting that can be given in three places, checked in this order: a command-line flag, a value in config.yaml, or this tool's own built-in fallback. """ if cliValue is not None: return cliValue configValue = config.get(configKey) if configValue is not None: return configValue return fallback def requireNonEmptyString(value, settingDescription): """Exit with a clear error if value isn't a usable non-empty string - catches mistakes like an empty --selector/--font flag or a default_font_family: "" in config.yaml, either of which would otherwise silently produce a broken CSS rule or font name later on.""" if not isinstance(value, str) or not value.strip(): sys.exit(f"Invalid {settingDescription}: {value!r} - must be a non-empty string.") def parseWholeNumber(value): """ Interpret value as a whole number, or return None if it isn't one. Used for default_max_widgets, which - unlike --max-widgets - isn't type-checked by argparse and can be any YAML value. Deliberately stricter than Python's own int(): int(10.5) truncates to 10 and int(True) succeeds as 1, both of which would silently accept a config mistake instead of rejecting it. """ if isinstance(value, bool): return None if isinstance(value, int): return value if isinstance(value, float): return int(value) if value.is_integer() else None if isinstance(value, str): try: return int(value.strip()) except ValueError: return None return None def main(): """ Entry point: parse arguments, load configuration, then either undo a previous run or scan for candidate widgets and apply font changes to them. This function only wires the pieces together - all the actual logic lives in the functions it calls. """ # Logging starts as early as possible so even a bad --log-file value or a # config problem below gets written to the console consistently. args = parseArgs() logger = setupLogger(args.logFile) # config.yaml supplies the Sisense connection details (read directly by # SisenseClient) plus the default_* settings resolved below. config = loadConfig(args.configPath) isAdmin = bool(config.get("is_user_admin")) widgetType = config.get("widget_type", "BloX") # use_temporary_ownership (see config.example.yaml and README.md # "Permissions") needs the API user's own id, read directly out of the # token rather than via a separate lookup call. useTemporaryOwnership = bool(config.get("use_temporary_ownership")) apiUserId = decodeJwtUserId(config.get("token", "")) if useTemporaryOwnership else None if useTemporaryOwnership and not apiUserId: logger.warning( "use_temporary_ownership is enabled in config.yaml, but the API user's id could not be " "read from token - this fallback will be skipped for the rest of this run." ) apiClient = SisenseClient(config_file=args.configPath, debug=False) # --undo works entirely from the backup file and skips everything below. if args.undo: undoChanges(apiClient, args.backupFile, logger, useTemporaryOwnership, apiUserId) return # Priority: CLI flag, then config.yaml, then built-in fallback - see # resolveSetting(). selector = resolveSetting(args.selector, config, "default_selector", fallbackSelector) requireNonEmptyString(selector, "selector (--selector / default_selector)") fontFamily = resolveSetting(args.fontFamily, config, "default_font_family", fallbackFontFamily) requireNonEmptyString(fontFamily, "font (--font / default_font_family)") fontMode = resolveSetting(args.fontMode, config, "default_font_mode", fallbackFontMode) if fontMode not in fontModeChoices: # Only reachable via a typo in config.yaml's default_font_mode - # argparse itself already restricts --font-mode to fontModeChoices. sys.exit( f"Invalid default_font_mode in config.yaml: {fontMode!r}. Must be one of: " f"{', '.join(fontModeChoices)}." ) # default_max_widgets isn't type-checked by argparse the way --max-widgets # is (it can only come from config.yaml here), so it needs its own # validation before being used as a number. rawMaxWidgets = resolveSetting(args.maxWidgets, config, "default_max_widgets", fallbackMaxWidgets) maxWidgetsSetting = parseWholeNumber(rawMaxWidgets) if maxWidgetsSetting is None: sys.exit(f"Invalid default_max_widgets in config.yaml: {rawMaxWidgets!r} - must be a whole number.") # --all, or a zero/negative limit from either source, both mean "no cap". maxWidgets = None if (args.allWidgets or maxWidgetsSetting <= 0) else maxWidgetsSetting if bool(args.dashboardId) != bool(args.widgetId): sys.exit("--dashboard-id and --widget-id must be used together.") if args.dashboardId and args.widgetId: # Skip the tenant scan entirely and go straight to one already-known # widget - useful for retrying, or verifying, a specific widget # without depending on dashboard discovery order (which can surface # unrelated widgets, or the same widget twice, on a tenant whose # dashboards have data quirks of their own). widget = fetchWidget(apiClient, args.dashboardId, args.widgetId, isAdmin, logger) if widget is None: sys.exit(f"Could not fetch widget {args.widgetId} on dashboard {args.dashboardId} - see the log above.") if widget.get("type") != widgetType: sys.exit( f"Widget {args.widgetId} is a {widget.get('type')!r} widget, not {widgetType!r} - refusing to " "change its font. Pass the id of a BloX widget instead." ) candidates = [ { "dashboardId": args.dashboardId, "dashboardTitle": "", "widgetId": args.widgetId, "widgetTitle": widget.get("title", ""), } ] logger.info(f"Targeting widget {args.widgetId} ({widget.get('title', '')!r}) directly - skipping the tenant scan.") else: dashboardHelper = Dashboard(api_client=apiClient) dashboards = dashboardHelper.get_all_dashboards() # On failure (unreachable domain, bad token, server error), pysisense # returns {"error": "..."} instead of a list - catch that here so it # surfaces as a clear message instead of a confusing crash further down. if not isinstance(dashboards, list): detail = dashboards.get("error", dashboards) if isinstance(dashboards, dict) else dashboards sys.exit( f"Could not retrieve dashboards from Sisense: {detail}\n\n" "Check that domain and token in config.yaml are correct, and that the server is reachable." ) scopeDescription = "every" if maxWidgets is None else f"up to {maxWidgets}" logger.info(f"Scanning {len(dashboards)} dashboard(s) for {scopeDescription} '{widgetType}' widget(s)...") candidates = findTargetWidgets(apiClient, dashboards, widgetType, isAdmin, maxWidgets, logger) logger.info(f"Found {len(candidates)} candidate widget(s).") # With args.apply False, this only previews - see applyChanges(). appliedCount, skippedCount = applyChanges( apiClient, candidates, selector, fontFamily, fontMode, args.apply, args.force, args.backupFile, isAdmin, args.quiet, args.verbose, logger, useTemporaryOwnership, apiUserId, ) logger.info(f"Done. Applied: {appliedCount}, skipped (already applied): {skippedCount}, candidates seen: {len(candidates)}.") # Surface the exact undo command after a real run, so it doesn't have to # be looked up in --help right when it might be needed. if not args.apply: logger.info("This was a dry run - nothing was written. Re-run with --apply to write changes.") elif appliedCount: logger.info(f"To revert everything just written, run: python {os.path.basename(__file__)} --undo --backup-file {args.backupFile}") if __name__ == "__main__": try: main() except KeyboardInterrupt: sys.exit("\nStopped.")

      Two Blox Widget's with different font families, and font colors
      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
      Oleksandr Holubovych
      • Strategy & Use Cases
               
      Oleksandr Holubovych
      Limiting the Number of Rows Displayed in a Pivot Widget
                       

      If your Pivot Query LIMIT is set high to meet exporting requirements but you only need a few rows displayed in a specific Pivot widget on the dashboard, you can use transformPivot to suppress rows beyond a defined threshold. The screenshot below depicts the result.

      To do so open the widget in Edit mode, go to the Edit Script tab, and paste: widget.transformPivot( { type: ['member', 'value', 'subtotal', 'grandtotal'] }, (metadata, cell) => { const threshold = 8; // maxDataRows (5) + headerRows (1) - 1 if (metadata.rowIndex > threshold) { cell.content = ''; cell.style = Object.assign(cell.style || {}, { color: 'transparent', backgroundColor: 'transparent', borderWidth: 0, padding: 0, fontSize: 0, lineHeight: 0, display: 'none' }); } } ); widget.on("ready", (w) => { const styleId = `hide-pg-${w.oid}`; if (document.getElementById(styleId)) return; document.head.insertAdjacentHTML('beforeend', ` <style id="${styleId}"> [widgetid="${w.oid}"] [class*="pagination"], [widgetid="${w.oid}"] [class*="Pagination"], [widgetid="${w.oid}"] .pivot-footer { display: none !important; } </style> `); }); Use the property threshold to control how many rows are visible. The pagination block is optional — remove it if users still need to page through the full dataset. For full transformPivot API details, see the Sisense Developer documentation . The script was valided on version 2026.2.2. 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.

      2 months ago
      0
               
    • Discussion
      Oleksandr Holubovych
      • Strategy & Use Cases
               
      Oleksandr Holubovych
      Place Data Labels Below the Mark on a Scatter Chart
                       

      By default, data labels on a Scatter chart overlap the dot directly.

      The script below repositions each label to sit cleanly beneath its mark:
      Open the widget in Edit mode, go to the Edit Script tab, and paste:Open the widget in Edit mode, go to the Script tab, and paste: widget.on('processresult', function(se, ev) { ev.result.series.forEach(function(series) { series.dataLabels = { enabled: true, y: 15, verticalAlign: 'top', allowOverlap: true }; }); }); The key is pairing verticalAlign: 'top' with a positive y offset — anchoring to the top of the dot and nudging downward places the label below it rather than on top. Increase y for a larger gap, decrease it to bring the label closer.The key is pairing verticalAlign: 'top' with a positive y offset — anchoring to the top of the dot and nudging downward places the label below it rather than on top. Increase y for a larger gap, decrease it to bring the label closer. The script was valided on version 2026.2.2. 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.

      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
               
    • 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
               
      • Add-ons & Plug-InsChevronRightIcon

      RE-POST: Metadata Plugin - Grouping Provider Example (Multiple Translations for Different User Groups)

                       

      This article shows a good way to implement a grouping provider in the Metadata Plugin . If you have already seen the previous article , this is a newer version of it. This solution covers the following use case: when multiple user groups use different system languages, the relevant metadata translation is applied to the user based on their assigned group. In the example below, we will use two different translations: Portuguese and Spanish, to demonstrate the plugin in action. Step 1: Create the Groups This plugin uses groups as its foundation, so you should begin by navigating to: Admin > User Management > Groups > + Add Group Create the groups required for the plugin setup. For example: Portuguese and Spanish. Step 2: Retrieve the Group IDs Go to the REST API section under the Admin tab. Execute the GET /groups API request to obtain the IDs for each group you created. Step 3: Configure the Plugin Now that you have the group IDs, you can begin configuring the plugin. There are a few concepts inside the plugin that you must understand. The configuration is split into two sections: Configurations that apply translations globally using globalDatasourceAliasing Configurations that apply translations at the datasource level using datasourceAliasing For each language you support, you should configure both sections inside run.js . Global Translation: // Global metadata aliases (not tied to one datasource). // Where this appears: // - dashboards: Dashboard names in Sisense UI (home/list/search/header titles). // - folders: Folder names in Sisense UI (left navigation and browse pages). const globalDatasourceAliasing_PT = { // Maps: <original dashboard name> -> <translated dashboard name> dashboards: { "General Usage": "Usos Gerais", }, // Maps: <original folder name> -> <translated folder name> folders: { "General": "Geral", }, }; This configuration defines translations that apply globally across Sisense, including dashboard names, folder names, and other global metadata. Datasource Translation: // Datasource-level metadata aliases. // Where this appears: // - tables: Table names in Data panel/model metadata. // - formulas: Custom formula names used in fields/widgets. // - hierarchies: Hierarchy names in filter panel and field browser. // - titles: Generic metadata titles resolved by Sisense alias engine. // - columns: Column/field names used in Data panel and widget builders. // - widgets: Widget titles shown on dashboards. const datasourceAliasing_PT = { // Maps: <table name> -> <translated table name> tables: { total: "Total", }, // Maps: <formula name> -> <translated formula name> formulas: { Revenue: "Receita", }, // Maps: <hierarchy name> -> <translated hierarchy name> hierarchies: { "Category by Brand and Age Range": "Categoria por marca e faixa etária", }, // Maps: <title key/name> -> <translated title> titles: { "General Usage": "Usos Gerais", }, // Column alias mappings. // Preferred structure per table: // columns: { <tableName>: { <columnName>: <translated column> } } columns: { purchases: { buyer_name: "Nome do Comprador", }, }, // Maps: <widget title> -> <translated widget title> widgets: { "Open Purchases": "Compras em Aberto", }, }; This section controls translations related to tables, formulas, widget titles, hierarchies, columns, and other datasource-specific metadata. The keys must exactly match the names used in your tables, widgets, formulas, and other metadata objects. Group Config: You can define multiple globalDatasourceAliasing and datasourceAliasing configurations inside the config variable. This array contains the mapping between Sisense groups and translation configurations. Here, you must use the group IDs retrieved in Step 2 and assign them to the appropriate translation configuration. Multiple groups can also share the same configuration. // Group-to-language routing configuration. // Where this is used: // - During provider execution, the user's group IDs are matched against `groupIds`. // - If matched, Sisense receives `globalAlias` + `aliasName` and applies translation. const config = [ { // Human-readable label (for maintainers/logging). language: "Portuguese Translation", // Sisense group IDs that should receive this translation set. groupIds: ["69c5488fd868b533ad8b9a43"], globalAlias: globalDatasourceAliasing, aliasName: datasourceAliasing_PT, }, { // Human-readable label (for maintainers/logging). language: "Spanish Translation", // Sisense group IDs that should receive this translation set. groupIds: ["29c5488fd868b533ad239a43"], globalAlias: globalDatasourceAliasing, aliasName: datasourceAliasing_ES, }, ]; Note: If a user belongs to more than one group with different translations configured, the default system language will be used. A notification similar to the example below will also be displayed. Step 4: Install the Plugin Import the Metadata Plugin with your custom configuration into the plugins directory using the File Manager. Once uploaded, enable the plugin and verify that the translations are being applied correctly. Changes Compared to Previous Version Minor improvements in code readability to make the plugin easier to understand. Added fallback error handling. In the previous version, any invalid configuration could stop all translations from working. Now, only the invalid configuration is ignored. Added debug logs to simplify troubleshooting and debugging. The plugin files are attached below for reference: plugin.json : { "name": "metadata", "source": [ "run.6.js" ], "style": [], "folderName": "metadata", "lastUpdate": "2026-05-05T19:49:50.832Z", "isEnabled": true, "version": "2.0.0" } run.6.js : /** * Metadata Translation Plugin (Group-Based) * * Translates Sisense metadata (field titles, formula names) based on the * Sisense group the logged-in user belongs to. * * How it works: * - On every datasource load, Sisense fires 'beforealiascontextinit'. * - This plugin registers two Provider functions (datasource + global) that * inspect the current user's groups and return the correct alias mapping. * - Sisense then replaces all matching metadata labels in widgets and the Data * panel with the aliased values before rendering. * * Grouping Provider (per Sisense docs): * - Each entry in `config` maps one or more Sisense group IDs to an alias object. * - Retrieve group IDs via the Sisense REST API: GET /api/v1/groups * - A user may belong to multiple groups; if more than one group has a distinct * translation configured, the plugin falls back to default language and shows a * dismissable notification informing the user of the conflict. * * Configuration: * - Update the datasource aliasing objects with the field * names exactly as they appear in the ElastiCube (case-insensitive). * - Update `groupIds` in `config` to match the actual group IDs in your instance. * - Add additional language blocks following the same pattern. * - `globalDatasourceAliasing` handles folder/dashboard name aliasing; populate * it if you also need to translate dashboard or folder names. * * References: * - Sisense Docs: https://docs.sisense.com/main/SisenseLinux/translating-sisense-metadata-on-linux.htm * - Community example: https://community.sisense.com/kb/add-ons_and_plug-ins/metadata-plugin---grouping-provider-example-multiple-translations-for-different-/17114 * * Before Implementation: * - Confirm group IDs via GET /api/v1/groups in the Sisense REST API explorer. * - Verify field names match exactly what is stored in the ElastiCube. * - Deploy this file alongside plugin.json into the Sisense plugins directory: * /opt/sisense/storage/plugins/<plugin-folder>/ * or upload via the Sisense File Manager. */ prism.run([ "$q", "$http", ($q, $http) => { const DEBUG_LOGS = true; const log = (step, payload = null, level = "info") => { if (!DEBUG_LOGS && level !== "error") return; const method = level === "error" ? console.error : console.log; if (payload === null || payload === undefined) { method("[Metadata Translation]", `[${level.toUpperCase()}]`, step); return; } method("[Metadata Translation]", `[${level.toUpperCase()}]`, step, payload); }; // Global metadata aliases (not tied to one datasource). // Where this appears: // - dashboards: Dashboard names in Sisense UI (home/list/search/header titles). // - folders: Folder names in Sisense UI (left navigation and browse pages). const globalDatasourceAliasing_PT = { // Maps: <original dashboard name> -> <translated dashboard name> dashboards: { "General Usage": "Usos Gerais", }, // Maps: <original folder name> -> <translated folder name> folders: { "General": "Geral", }, }; const globalDatasourceAliasing_ES = { dashboards: { "General Usage": "Uso General", }, folders: { "General": "Geral", }, }; // Datasource-level metadata aliases. // Where this appears: // - tables: Table names in Data panel/model metadata. // - formulas: Custom formula names used in fields/widgets. // - hierarchies: Hierarchy names in filter panel and field browser. // - titles: Generic metadata titles resolved by Sisense alias engine. // - columns: Column/field names used in Data panel and widget builders. // - widgets: Widget titles shown on dashboards. const datasourceAliasing_PT = { // Maps: <table name> -> <translated table name> tables: { total: "Total", }, // Maps: <formula name> -> <translated formula name> formulas: { Revenue: "Receita", }, // Maps: <hierarchy name> -> <translated hierarchy name> hierarchies: { "Category by Brand and Age Range": "Categoria por marca e faixa etária", }, // Maps: <title key/name> -> <translated title> titles: { "General Usage": "Usos Gerais", }, // Column alias mappings. // Preferred structure per table: // columns: { <tableName>: { <columnName>: <translated column> } } columns: { purchases: { buyer_name: "Nome do Comprador", }, }, // Maps: <widget title> -> <translated widget title> widgets: { "Open Purchases": "Compras em Aberto", }, }; const datasourceAliasing_ES = { // Maps: <table name> -> <translated table name> tables: { total: "Total", }, // Maps: <formula name> -> <translated formula name> formulas: { Revenue: "Ganancia", }, // Maps: <hierarchy name> -> <translated hierarchy name> hierarchies: { "Category by Brand and Age Range": "Categoría por marca y rango de edad", }, // Maps: <title key/name> -> <translated title> titles: { "General Usage": "Uso General", }, // Column alias mappings. // Preferred structure per table: // columns: { <tableName>: { <columnName>: <translated column> } } columns: { purchases: { buyer_name: "Nombre del Comprador", }, }, // Maps: <widget title> -> <translated widget title> widgets: { "Open Purchases": "Compras Abiertas", }, }; // Group-to-language routing configuration. // Where this is used: // - During provider execution, the user's group IDs are matched against `groupIds`. // - If matched, Sisense receives `globalAlias` + `aliasName` and applies translation. const config = [ { // Human-readable label (for maintainers/logging). language: "Portuguese Translation", // Sisense group IDs that should receive this translation set. groupIds: ["69c5488fd868b533ad8b9a43"], globalAlias: globalDatasourceAliasing_PT, aliasName: datasourceAliasing_PT, }, { // Human-readable label (for maintainers/logging). language: "Spanish Translation", // Sisense group IDs that should receive this translation set. groupIds: ["29c6488fd868b533ad8b9a43"], globalAlias: globalDatasourceAliasing_ES, aliasName: datasourceAliasing_ES, }, ]; const isObj = (v) => v && typeof v === "object" && !Array.isArray(v); const cleanMap = (obj) => Object.fromEntries( Object.entries(obj || {}).filter( ([k, v]) => typeof k === "string" && typeof v === "string", ), ); // Normalizes alias payloads before resolving provider callbacks. // - Retains only supported Sisense sections. // - Drops empty sections and invalid key/value pairs. // - Validates nested column mappings per table. const sanitizeAliasing = (input = {}) => { const result = {}; const sections = [ "tables", "formulas", "hierarchies", "titles", "widgets", "dashboards", "folders", ]; for (const key of sections) { if (isObj(input[key])) { const cleaned = cleanMap(input[key]); if (Object.keys(cleaned).length) result[key] = cleaned; } } if (isObj(input.columns)) { const cols = Object.fromEntries( Object.entries(input.columns) .map(([table, colsObj]) => [ table, isObj(colsObj) ? cleanMap(colsObj) : null, ]) .filter(([, v]) => v && Object.keys(v).length), ); if (Object.keys(cols).length) result.columns = cols; } return result; } // Returns the config block that contains the given group ID. // If no group is configured, returns null (plugin falls back to default labels). const getConfigByID = (groupId) => { const matchedConfig = config.find((cfg) => cfg.groupIds.includes(groupId), ); return matchedConfig || null; }; // Attempts to read current user groups from the in-memory prism session. // This is the fastest path and avoids an API call when session data is present. const getGroups = async () => { await $q.resolve(); if (prism && prism.user && prism.user.groupsName) { log("Resolved user groups from prism session", prism.user.groupsName); return prism.user.groupsName; } log("User groups unavailable in prism session; API fallback may be required"); return null; }; // Shows a dismissable warning when a user belongs to multiple translated groups. // In that conflict case the plugin intentionally resolves null (default language). const dispatchNotification = () => { log("Multiple translated groups detected; showing conflict notification"); const notificationDiv = document.createElement("div"); notificationDiv.classList.add("notification_metadata"); notificationDiv.style = ` background: #444f67; padding: 10px; position: absolute; z-index: 999; border-radius: 10px; margin: 3% auto; color: #fafafa; width: 30%; left: 50%; transform: translateX(-50%); `; const notificationText = document.createElement("span"); notificationText.textContent = "You belong to multiple groups. To ensure a consistent user experience, the platform language has been set to the default English version."; notificationDiv.appendChild(notificationText); const dismissButton = document.createElement("button"); dismissButton.textContent = "Dismiss"; dismissButton.style = ` background: none; color: #39a3fa; border: none; cursor: pointer; `; dismissButton.addEventListener("click", (e) => { e.preventDefault(); notificationDiv.style.display = "none"; log("Conflict notification dismissed by user"); }); notificationDiv.appendChild(dismissButton); document.body.appendChild(notificationDiv); }; // Global provider: resolves dashboard/folder aliases. // Steps: // 1) Get user groups (session first, API fallback). // 2) Collect distinct global alias maps configured for those groups. // 3) If more than one map exists, notify and resolve null to avoid ambiguity. // 4) Otherwise resolve the sanitized alias map. const globalDatasourceProvider = async (resolve, reject) => { try { log("globalDatasourceProvider started"); let groups = await getGroups(); // Sometimes the groups are not available in the session, so we need to fetch them from the API. if (!groups) { log("Fetching user groups from /api/users/loggedin"); const response = await $http.get("/api/users/loggedin"); groups = response.data.groupsName; log("Resolved user groups from API", groups); } const globalAlias = groups .map((g) => getConfigByID(g.id)) .filter((cfg) => cfg !== null) .map((cfg) => cfg.globalAlias); const alias = Array.from(new Set(globalAlias)); log("Computed unique global aliases", alias.length); if (alias.length > 1) { dispatchNotification(); log( "Conflicting global aliases found; resolving null to use default language", ); return resolve(null); } log("Resolving sanitized global alias map", alias[0]); return resolve(sanitizeAliasing(alias[0])); } catch (error) { log("globalDatasourceProvider failed", error, "error"); reject(error); } }; // Datasource provider: resolves metadata aliases (tables, columns, formulas, etc.). // Mirrors the global provider conflict behavior: multiple distinct alias maps // trigger a warning and fallback to default labels. const datasourceProvider = async (_, resolve, reject) => { try { log("datasourceProvider started"); const groups = await getGroups(); log("Processing datasource aliases for groups", groups); const aliasNames = groups .map((g) => getConfigByID(g.id)) .filter((cfg) => cfg !== null) .map((cfg) => cfg.aliasName); const alias = Array.from(new Set(aliasNames)); log("Computed unique datasource aliases", alias.length); if (alias.length > 1) { dispatchNotification(); log( "Conflicting datasource aliases found; resolving null to use default language", ); return resolve(null); } log("Resolving sanitized datasource alias map", alias[0]); return resolve(sanitizeAliasing(alias[0])); } catch (error) { log("datasourceProvider failed", error, "error"); reject(error); } }; // Registers providers before Sisense initializes alias context. // `2000` is the provider timeout in milliseconds. prism.on("beforealiascontextinit", function (ev, args) { log("beforealiascontextinit fired; registering alias providers"); args.register(datasourceProvider, globalDatasourceProvider, 2000); }); }, ]); References/Related Content  Metadata Plugin Repository. Sisense Documentation. Previous Post. Sisense User Groups Documentation. Disclaimer : Please note that this blog post contains one possible custom workaround solution for users with similar use cases. We cannot guarantee that the custom code solution described in this post will work in every scenario or with every Sisense software version. As such, we strongly advise users to test solutions in their environment prior to deploying them to ensure that the solutions proffered function as desired in their environment. For the avoidance of doubt, the content of this blog post is provided to you “as-is” and without warranty of any kind, express, implied, or otherwise, including without limitation any warranty of security and or fitness for a particular purpose. The workaround solution described in this post incorporates custom coding, which is outside the Sisense product development environment and is, therefore, not covered by Sisense warranty and support services.

      Micael Santana
      Micael SantanaPosted 3 months ago
      0
               
    • Blog banner
      • Add-ons & Plug-InsChevronRightIcon

      Managing Sisense Plugin States via the REST API

                                                                               

      Managing Sisense Plugin States via the REST API Overview Manually enabling and disabling Sisense plugins through the admin interface can become time consuming, particularly when keeping multiple Sisense server instances in a consistent state, switching active plugin lists quickly for testing, restoring a previous known working plugin list after an upgrade or support session, or iterating on a plugin during development where having only one plugin active significantly reduces build time for any plugin changes to become active. This article describes a Python command line (CLI) tool that manages Sisense plugin states via the Sisense REST API, using a simple configuration file as the config. The tool supports enabling and disabling plugins, isolating (enabling only one and disabling all others) a single plugin for faster development builds, saving and restoring named snapshots of plugin state, and previewing changes before applying them. This tool does not install or uninstall plugins, and does not update plugins or upgrade/downgrade or modify the code within the plugins. The script, configuration file, and dependencies are included in this article. Sisense instances often have many plugins active, with the enabled list changing over time. Common scenarios include: Plugins being left enabled or disabled after support sessions or testing, or enabling or disabling all plugins The need to quickly reach a server plugin state with no plugins enabled, to isolate whether a plugin is causing a potential issue Maintaining a consistent plugin list across multiple Sisense environments or after upgrades Needing to roll back to a previous plugins active state after a change is potentially creating issues (This tool does not rollback plugin versions, only active plugin lists) Performing these operations manually through the Sisense UI admin panel is practical for occasional changes, but becomes inefficient when the changes are frequent, when many plugins are involved, or when the same configuration needs to be applied reliably and repeatably. Description The plugin manager script reads an enable_plugins list from a configuration file and syncs the Sisense instance to match via the REST API. The script: Enables plugins listed in enable_plugins in the configuration file Optionally disables plugins listed in disable_plugins Leaves all other plugins in their current state by default Saves a timestamped snapshot of the current plugin state before applying any changes, providing an automatic rollback point Supports restoring any saved snapshot to return to a previous state A strict mode is also available, which disables any currently enabled plugin that is not listed in enable_plugins , making the instance exactly match the configuration file. The script also supports previewing what would change without making any API calls, and can save named snapshots for later reference or comparison. Plugin name matching Plugin names in the configuration file accept either the short form ( SwitchDimension ) or the full form ( plugin-SwitchDimension ). Matching is case-insensitive. This applies both to the configuration file and to command line arguments. Prerequisites Python 3.7 or later Network access to the Sisense instance (The tool runs remotely and does not need to be installed directly on the server, or SSH access) A Sisense API token, which can be generated from Admin > REST API > v1.0 > GET /authentication/tokens/api Setup Install the required Python packages: pip install requests 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 . This is 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.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.py --restore-snapshot To preview what would change without making any API calls: python plugin_manager.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.py --strict Testing on a stock Sisense instance Plugin interference is a common source of issues that are hard 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.py --strict # restore when done python plugin_manager.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.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.py --save-snapshot before-upgrade # list all saved snapshots python plugin_manager.py --list-snapshots # restore a specific snapshot python plugin_manager.py --restore-snapshot before-upgrade Including --log-version when saving a snapshot records the version of each plugin at that point in time, which can be useful for tracking plugin versions across upgrades. Full option reference usage: plugin_manager.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 the REST API. 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 (manual --save-snapshot or auto saved snapshot) --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 for each plugin. --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 the REST API 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 Gives server administrators a record of plugin state changes over time (done via the tool and saved via snapshots) 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. Whether maintaining a consistent production environment, isolating issues related to plugins, or iterating on plugin development, the tool provides a practical and repeatable approach to plugin state management. The code is not compressed or obfuscated and can be modified as needed or used as a reference for similar tools. Full Python Code: """ Sisense Plugin Manager: manage Sisense plugin states via the REST API. Overview Manages Sisense plugin states via the REST API. 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 Eliminate plugins as a potential cause of a bug or issue. Clear enable_plugins in config.yaml, run --strict to disable everything, test, then restore when done, the snapshot is saved automatically before changes: # (clear enable_plugins in config.yaml) python plugin_manager.py --strict # ... reproduce the issue on a vanilla instance ... python plugin_manager.py --restore-snapshot # restores most recent Develop and iterate on a single plugin quickly With many plugins installed Sisense rebuilds all of them on each change. --enable-one isolates a single plugin and disables the rest, cutting rebuild time to a fraction on instances with many plugins: python plugin_manager.py --enable-one MyPlugin Roll back any change instantly Every run saves a timestamped snapshot of the state it found before making changes. Use --list-snapshots to find an earlier state and --restore-snapshot to return to it. Usage # Enable listed plugins, disable listed disable_plugins, leave rest unchanged python plugin_manager.py # Strict: also disables any enabled plugin not in enable_plugins python plugin_manager.py --strict # Preview what would change (no API calls, no auto snapshot) python plugin_manager.py --dry-run python plugin_manager.py --strict --dry-run # Run only one half of the sync python plugin_manager.py --enable-only # enable listed; skip all disables python plugin_manager.py --disable-only # disable listed; skip all enables # Enable exactly one plugin and disable every other currently enabled plugin python plugin_manager.py --enable-one SwitchDimension # Snapshots python plugin_manager.py --save-snapshot [NAME] # name is optional python plugin_manager.py --save-snapshot [NAME] --log-version # also record per-plugin versions python plugin_manager.py --list-snapshots python plugin_manager.py --restore-snapshot [NAME] # omit NAME → 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 requests pyyaml # Python 3.7+ 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 from typing import Optional import requests import yaml # Constants SCRIPT_DIR = Path(__file__).parent DEFAULT_CONFIG_PATH = SCRIPT_DIR / "config.yaml" SNAPSHOTS_DIR = SCRIPT_DIR / "snapshots" PLUGIN_PAGE_SIZE = 20 # plugins returned per page by GET /api/v1/plugins # Config helpers def load_config(config_path: Path) -> dict: """Load and return the YAML config file, printing an error and exiting if missing.""" if not config_path.exists(): print(f"Config file not found: {config_path}") print("Copy config.yaml.example to config.yaml and fill in your instance URL and API token.") sys.exit(1) with config_path.open() as file_handle: return yaml.safe_load(file_handle) def make_auth_headers(api_token: str) -> dict: """Return the Authorization + Content-Type headers required by the Sisense REST API.""" return { "Authorization": f"Bearer {api_token}", "Content-Type": "application/json", } # Plugin name normalization def normalize_plugin_name(raw_name: str) -> str: """ Return a canonical form: lowercase with any leading 'plugin-' prefix stripped. Both 'plugin-SwitchDimension' and 'SwitchDimension' normalize to 'switchdimension', so callers do not need to be consistent about the prefix. """ lowered = raw_name.lower() return lowered[len("plugin-"):] if lowered.startswith("plugin-") else lowered def build_name_lookup(plugin_names: list) -> set: """ Build a set that matches a plugin regardless of whether the caller used the 'plugin-' prefix. Each name contributes two entries: the bare normalized form and the 'plugin-<normalized>' form. Use with plugin_matches_lookup() to test whether an API plugin object is covered by a configured list. """ lookup: set = set() for name in plugin_names: normalized = normalize_plugin_name(name) lookup.add(normalized) lookup.add("plugin-" + normalized) return lookup def plugin_matches_lookup(plugin: dict, name_lookup: set) -> bool: """ Return True if either the plugin's folderName or its API name appears in name_lookup (case insensitive, prefix agnostic). """ plugin_identifiers = {plugin["folderName"].lower(), plugin["name"].lower()} return bool(plugin_identifiers & name_lookup) # Plugin list API def fetch_all_plugins(base_url: str, auth_headers: dict) -> list: """ Retrieve the full plugin list from the Sisense instance via paginated GET requests. Prints progress to stdout and exits on HTTP error. """ print("Fetching plugin list...") try: all_plugins = _paginate_plugins(base_url, auth_headers) except requests.HTTPError as http_error: print(f"Failed to fetch plugins: {http_error}") sys.exit(1) print(f" {len(all_plugins)} plugins total\n") return all_plugins def _paginate_plugins(base_url: str, auth_headers: dict) -> list: """Internal: collect every page from GET /api/v1/plugins.""" endpoint = f"{base_url}/api/v1/plugins" all_plugins: list = [] offset = 0 while True: response = requests.get( endpoint, headers=auth_headers, params={"limit": PLUGIN_PAGE_SIZE, "skip": offset}, timeout=30, ) response.raise_for_status() payload = response.json() page = payload.get("plugins", []) all_plugins.extend(page) offset += len(page) total_count = payload.get("count", 0) print(f" Fetched {offset} / {total_count} plugins...") if offset >= total_count or not page: break return all_plugins # Plugin versions def extract_plugin_versions(plugins: list) -> dict: """ Extract the version field from each plugin object in an API response. Returns a dict mapping folderName → version string for every plugin that carries a version field. Plugins with no version field are omitted so the caller can treat an absent key as 'version unknown'. """ versions: dict = {} for plugin in plugins: version_value = plugin.get("version") or plugin.get("pluginVersion") if version_value: versions[plugin["folderName"]] = str(version_value) return versions # Applying changes def _patch_plugins(base_url: str, auth_headers: dict, updates: list) -> None: """Send a PATCH /api/v1/plugins request. Raises requests.HTTPError on failure.""" response = requests.patch( f"{base_url}/api/v1/plugins", headers=auth_headers, json=updates, timeout=60, ) response.raise_for_status() def apply_plugin_updates( base_url: str, auth_headers: dict, updates: list, use_bulk: bool, ) -> tuple: """ Apply a list of enable/disable changes and print the outcome per plugin. use_bulk=True → single PATCH with all updates at once (faster, one round-trip). use_bulk=False → one PATCH per plugin (slower, easier to diagnose partial failures). Returns (success_count, error_count). """ if use_bulk: print(f"Sending bulk request ({len(updates)} plugin(s))...") try: _patch_plugins(base_url, auth_headers, updates) except requests.HTTPError as http_error: print(f" [ERROR] Bulk request failed: {http_error}") sys.exit(1) for update in updates: status_label = "ENABLED" if update["isEnabled"] else "DISABLED" print(f" [{status_label}] {update['folderName']}") return len(updates), 0 success_count = error_count = 0 for update in updates: folder_name = update["folderName"] status_label = "ENABLED" if update["isEnabled"] else "DISABLED" try: _patch_plugins(base_url, auth_headers, [update]) print(f" [{status_label}] {folder_name}") success_count += 1 except requests.HTTPError as http_error: print(f" [ERROR] {folder_name}: {http_error}") error_count += 1 return success_count, error_count # Snapshot I/O def save_snapshot( snapshot_name: str, enabled_folders: list, *, disabled_folders: Optional[list] = None, plugin_versions: Optional[dict] = None, ) -> Path: """ Write a snapshot YAML file to snapshots/<snapshot_name>.yaml. Fields written (in this order): enable_plugins: sorted list of currently enabled folderNames disabled_plugins: sorted list of currently-disabled folderNames (when provided) plugin_versions: {folderName: version} for each plugin (when provided) created: ISO-8601 UTC timestamp Key ordering is preserved (sort_keys=False) so the file reads naturally. """ SNAPSHOTS_DIR.mkdir(exist_ok=True) # created goes last so the plugin lists appear first in the file snapshot_data: dict = {"enable_plugins": sorted(enabled_folders)} if disabled_folders is not None: snapshot_data["disabled_plugins"] = sorted(disabled_folders) if plugin_versions: snapshot_data["plugin_versions"] = plugin_versions snapshot_data["created"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") snapshot_path = SNAPSHOTS_DIR / f"{snapshot_name}.yaml" with snapshot_path.open("w") as file_handle: yaml.dump(snapshot_data, file_handle, sort_keys=False, default_flow_style=False, allow_unicode=True) return snapshot_path def _snapshot_enable_list(snapshot: dict) -> list: """ Return the enabled plugin list from a snapshot, handling both the current 'enable_plugins' key and the legacy 'plugins' key used by older snapshots. """ return snapshot.get("enable_plugins") or snapshot.get("plugins", []) def load_snapshot(snapshot_name: str) -> dict: """Load a snapshot by stem name, exiting with an error if the file is missing.""" snapshot_path = SNAPSHOTS_DIR / f"{snapshot_name}.yaml" if not snapshot_path.exists(): print(f"Snapshot not found: {snapshot_path}") sys.exit(1) with snapshot_path.open() as file_handle: return yaml.safe_load(file_handle) def list_snapshot_files() -> list: """Return all snapshot YAML files in the snapshots directory, sorted by filename.""" if not SNAPSHOTS_DIR.exists(): return [] return sorted(SNAPSHOTS_DIR.glob("*.yaml")) def latest_snapshot_name() -> str: """ Return the stem of the snapshot with the most recent 'created' timestamp. Exits if no snapshots exist. """ snapshot_files = list_snapshot_files() if not snapshot_files: print("No snapshots found.") sys.exit(1) def created_timestamp(path: Path) -> str: with path.open() as file_handle: return yaml.safe_load(file_handle).get("created", "") return max(snapshot_files, key=created_timestamp).stem def auto_save_snapshot(plugins: list, include_version: bool) -> None: """ Save a snapshot from an already fetched plugin list, before applying changes. Accepts the plugin list fetched at the start of the run so no second API call is needed. Called before any changes are applied, giving every run that changes state a rollback point. Failures are caught and reported as a warning so a snapshot error never blocks the intended change. """ snapshot_name = datetime.now().strftime("auto_%Y%m%d_%H%M%S") print(f"\nSaving snapshot '{snapshot_name}' before applying changes...") try: enabled_folders = [p["folderName"] for p in plugins if p.get("isEnabled")] disabled_folders = [p["folderName"] for p in plugins if not p.get("isEnabled")] plugin_versions = extract_plugin_versions(plugins) if include_version else None snapshot_path = save_snapshot( snapshot_name, enabled_folders, disabled_folders=disabled_folders or None, plugin_versions=plugin_versions, ) print(f" Saved to {snapshot_path} ({len(enabled_folders)} enabled / {len(disabled_folders)} disabled)") except Exception as exc: print(f" Warning: auto snapshot failed: {exc}") # Sync computation def compute_plugin_updates( all_plugins: list, enable_names: list, disable_names: list, *, strict: bool, enable_only: bool, disable_only: bool, ) -> tuple: """ Determine which plugins need their state changed. Default behavior (strict=False): - Enable: plugins in enable_names that are currently disabled. - Disable: plugins in disable_names that are currently enabled. - Leave alone: everything else (not mentioned in either list). With strict=True: - Enable: same as default. - Disable: plugins in disable_names, PLUS any currently enabled plugin that does not appear in enable_names. This makes the instance exactly match enable_names. Direction overrides (applied after the above): enable_only=True → clears to_disable entirely. disable_only=True → clears to_enable entirely. Returns: to_enable: sorted folderNames to enable to_disable: sorted folderNames to disable unmatched_names: entries from enable_names that matched no instance plugin """ enable_lookup = build_name_lookup(enable_names) disable_lookup = build_name_lookup(disable_names) # Plugins from the instance that match the enable list matched_enable_plugins = [p for p in all_plugins if plugin_matches_lookup(p, enable_lookup)] enable_folder_set = {plugin["folderName"] for plugin in matched_enable_plugins} # Plugins that should be enabled but currently are not to_enable: list = [] if disable_only else sorted( plugin["folderName"] for plugin in matched_enable_plugins if not plugin.get("isEnabled") ) # Plugins that should be disabled but currently are enabled if enable_only: to_disable: list = [] elif strict: # Strict: disable every enabled plugin not in the enable list to_disable = sorted( plugin["folderName"] for plugin in all_plugins if plugin.get("isEnabled") and plugin["folderName"] not in enable_folder_set ) else: # Soft: only disable what is explicitly listed in disable_plugins to_disable = sorted( plugin["folderName"] for plugin in all_plugins if plugin.get("isEnabled") and plugin_matches_lookup(plugin, disable_lookup) ) # Entries from enable_names that found no matching plugin in the instance matched_normalized_names = ( {normalize_plugin_name(plugin["folderName"]) for plugin in matched_enable_plugins} | {normalize_plugin_name(plugin["name"]) for plugin in matched_enable_plugins} ) unmatched_names = sorted( name for name in enable_names if normalize_plugin_name(name) not in matched_normalized_names ) return to_enable, to_disable, unmatched_names def _build_update_payload(to_enable: list, to_disable: list) -> list: """Combine enable and disable lists into the PATCH payload format.""" return ( [{"folderName": folder_name, "isEnabled": True} for folder_name in to_enable] + [{"folderName": folder_name, "isEnabled": False} for folder_name in to_disable] ) def _execute_sync( base_url: str, auth_headers: dict, to_enable: list, to_disable: list, use_bulk: bool, ) -> int: """ Build the PATCH payload from to_enable / to_disable and apply it. Prints 'Nothing to change.' if both lists are empty. Returns the error count (0 on full success). """ updates = _build_update_payload(to_enable, to_disable) if not updates: print("Nothing to change.") return 0 _, error_count = apply_plugin_updates(base_url, auth_headers, updates, use_bulk) return error_count # Output helpers def _print_update_plan( to_enable: list, to_disable: list, already_correct_count: int, untouched_count: int = 0, ) -> None: """Print a summary of pending changes before they are applied.""" print(f" To enable: {len(to_enable)}") print(f" To disable: {len(to_disable)}") if already_correct_count > 0: print(f" Already set: {already_correct_count}") if untouched_count > 0: print(f" Untouched: {untouched_count} (not listed in config; left as-is)") print() def _print_dry_run_preview(to_enable: list, to_disable: list) -> None: """Print the would-be changes when --dry-run is active.""" if not to_enable and not to_disable: print("Nothing to change.") if to_enable: print("Would enable:") for folder_name in to_enable: print(f" + {folder_name}") if to_disable: print("Would disable:") for folder_name in to_disable: print(f" - {folder_name}") print("\n[DRY RUN] No changes made.") def _report_unmatched_names(unmatched_names: list) -> None: """Warn about configured plugin names that matched nothing in the instance.""" if unmatched_names: print(f"\nNot found in instance ({len(unmatched_names)}):") for name in unmatched_names: print(f" - {name}") # Commands def cmd_sync_to_config( base_url: str, auth_headers: dict, enable_names: list, disable_names: list, *, dry_run: bool, use_bulk: bool, strict: bool, enable_only: bool, disable_only: bool, skip_snapshot: bool, include_version: bool, ) -> None: """ Default command: sync the instance to match config.yaml. Enables plugins in enable_plugins; in strict mode, also disables any currently enabled plugin not in enable_plugins. Without strict, only plugins explicitly in disable_plugins are disabled. """ all_plugins = fetch_all_plugins(base_url, auth_headers) to_enable, to_disable, unmatched_names = compute_plugin_updates( all_plugins, enable_names, disable_names, strict=strict, enable_only=enable_only, disable_only=disable_only, ) enable_lookup = build_name_lookup(enable_names) disable_lookup = build_name_lookup(disable_names) matched_plugins = [p for p in all_plugins if plugin_matches_lookup(p, enable_lookup)] already_correctly_enabled = sum(1 for p in matched_plugins if p.get("isEnabled")) # Untouched: plugins in neither list, only relevant without --strict, where # we deliberately leave them alone untouched_count = 0 if strict or enable_only else sum( 1 for p in all_plugins if not plugin_matches_lookup(p, enable_lookup) and not plugin_matches_lookup(p, disable_lookup) ) # Table of plugins matched by the enable list if matched_plugins: print(f"Matched {len(matched_plugins)} plugin(s) from enable_plugins:\n") print(f" {'Folder name':<45} {'API name':<35} {'Enabled'}") print(f" {'-'*45} {'-'*35} {'-'*7}") for plugin in sorted(matched_plugins, key=lambda p: p["folderName"]): print(f" {plugin['folderName']:<45} {plugin['name']:<35} {plugin['isEnabled']}") print() _print_update_plan(to_enable, to_disable, already_correctly_enabled, untouched_count) if dry_run: _print_dry_run_preview(to_enable, to_disable) _report_unmatched_names(unmatched_names) return if not skip_snapshot and (to_enable or to_disable): auto_save_snapshot(all_plugins, include_version) error_count = _execute_sync(base_url, auth_headers, to_enable, to_disable, use_bulk) print(f"\nDone. Enabled: {len(to_enable)} Disabled: {len(to_disable)} Errors: {error_count}") _report_unmatched_names(unmatched_names) def cmd_enable_single_plugin( base_url: str, auth_headers: dict, plugin_name: str, *, dry_run: bool, use_bulk: bool, skip_snapshot: bool, include_version: bool, ) -> None: """ Enable exactly one plugin and disable every other currently enabled plugin. The target plugin is identified by name (short or full form, case insensitive). This command does not consult enable_plugins or disable_plugins in config. """ target_name_lookup = build_name_lookup([plugin_name]) all_plugins = fetch_all_plugins(base_url, auth_headers) target_plugin = next( (plugin for plugin in all_plugins if plugin_matches_lookup(plugin, target_name_lookup)), None, ) if target_plugin 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_plugin["folderName"] target_currently_on = target_plugin.get("isEnabled", False) to_enable = [] if target_currently_on else [target_folder] to_disable = sorted( plugin["folderName"] for plugin in all_plugins if plugin.get("isEnabled") and plugin["folderName"] != target_folder ) print(f"Target plugin: {target_folder}") print(f" Currently: {'enabled' if target_currently_on else 'disabled'}\n") _print_update_plan( to_enable, to_disable, already_correct_count=1 if target_currently_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) error_count = _execute_sync(base_url, auth_headers, to_enable, to_disable, use_bulk) print(f"\nDone. Enabled: {len(to_enable)} Disabled: {len(to_disable)} Errors: {error_count}") def cmd_save_named_snapshot( base_url: str, auth_headers: dict, snapshot_name: str, include_version: bool, ) -> None: """Save the current plugin state as a named snapshot file.""" all_plugins = fetch_all_plugins(base_url, auth_headers) enabled_folders = [plugin["folderName"] for plugin in all_plugins if plugin.get("isEnabled")] disabled_folders = [plugin["folderName"] for plugin in all_plugins if not plugin.get("isEnabled")] plugin_versions = extract_plugin_versions(all_plugins) if include_version else None snapshot_path = save_snapshot( snapshot_name, enabled_folders, disabled_folders=disabled_folders or None, plugin_versions=plugin_versions, ) print(f"\nSnapshot '{snapshot_name}' saved to {snapshot_path}") print(f" {len(enabled_folders)} enabled / {len(disabled_folders)} disabled plugin(s) captured.") if plugin_versions is not None: print(f" Versions recorded for {len(plugin_versions)} plugin(s).") def cmd_list_snapshots() -> None: """Print a formatted table of all saved snapshots.""" snapshot_files = list_snapshot_files() if not snapshot_files: print("No snapshots found.") return print(f"Snapshots ({len(snapshot_files)}):\n") print(f" {'Name':<32} {'Created':<22} {'Enabled':<9} {'Versions'}") print(f" {'-'*32} {'-'*22} {'-'*9} {'-'*8}") for snapshot_path in snapshot_files: with snapshot_path.open() as file_handle: snapshot_data = yaml.safe_load(file_handle) enabled_count = len(_snapshot_enable_list(snapshot_data)) versions_logged = "yes" if snapshot_data.get("plugin_versions") else "no" print( f" {snapshot_path.stem:<32} " f"{snapshot_data.get('created', 'unknown'):<22} " f"{enabled_count:<9} " f"{versions_logged}" ) def cmd_restore_from_snapshot( base_url: str, auth_headers: dict, snapshot_name: str, *, dry_run: bool, use_bulk: bool, enable_only: bool, disable_only: bool, skip_snapshot: bool, include_version: bool, ) -> None: """ Restore plugin state to exactly match a saved snapshot. Restore is always strict: plugins in the snapshot are enabled and all other currently enabled plugins are disabled, matching the state at snapshot time. Use --enable-only to skip the disable step (only enable what the snapshot lists). """ snapshot = load_snapshot(snapshot_name) snapshot_enable_list = _snapshot_enable_list(snapshot) print(f"Snapshot '{snapshot_name}' ({snapshot.get('created', 'unknown')})") if snapshot.get("disabled_plugins"): print(f" {len(snapshot_enable_list)} enabled / {len(snapshot['disabled_plugins'])} disabled at snapshot time") else: print(f" {len(snapshot_enable_list)} plugin(s) enabled in snapshot") if snapshot.get("plugin_versions"): print(f" Plugin versions recorded: {len(snapshot['plugin_versions'])}") print() all_plugins = fetch_all_plugins(base_url, auth_headers) # Restore is inherently strict: disable anything not in the snapshot # unless the caller asked only to enable (enable_only=True). to_enable, to_disable, unmatched_names = compute_plugin_updates( all_plugins, enable_names=snapshot_enable_list, disable_names=[], strict=not enable_only, enable_only=enable_only, disable_only=disable_only, ) already_correct_count = max( 0, len(snapshot_enable_list) - len(to_enable) - len(unmatched_names) ) _print_update_plan(to_enable, to_disable, already_correct_count) if dry_run: _print_dry_run_preview(to_enable, to_disable) _report_unmatched_names(unmatched_names) return if not skip_snapshot and (to_enable or to_disable): auto_save_snapshot(all_plugins, include_version) error_count = _execute_sync(base_url, auth_headers, to_enable, to_disable, use_bulk) print(f"\nDone. Enabled: {len(to_enable)} Disabled: {len(to_disable)} Errors: {error_count}") _report_unmatched_names(unmatched_names) # Main def main() -> None: import argparse parser = argparse.ArgumentParser( prog="plugin_manager.py", description="Manage Sisense plugin states via the REST API.", 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." ), ) # Universal flags parser.add_argument( "--config", default=str(DEFAULT_CONFIG_PATH), 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 flags (mutually exclusive) 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 (mutually exclusive) 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() # --list-snapshots reads local files only; no config or API connection needed if args.list_snapshots: cmd_list_snapshots() return # Load config and connect config = load_config(Path(args.config)) sisense_config = config.get("sisense", {}) base_url = sisense_config.get("url", "").rstrip("/") api_token = sisense_config.get("token", "") if not base_url or not api_token: print("config.yaml must include sisense.url and sisense.token.") sys.exit(1) auth_headers = make_auth_headers(api_token) use_bulk = sisense_config.get("bulk", True) and not args.no_bulk # Support both 'enable_plugins' (current key) and 'plugins' (legacy key) enable_names = config.get("enable_plugins") or config.get("plugins", []) disable_names = config.get("disable_plugins", []) # Dispatch if args.enable_one: cmd_enable_single_plugin( base_url, auth_headers, args.enable_one, dry_run=args.dry_run, use_bulk=use_bulk, skip_snapshot=args.skip_snapshot, include_version=args.log_version, ) elif args.save_snapshot is not None: snapshot_name = ( datetime.now().strftime("snapshot_%Y%m%d_%H%M%S") if args.save_snapshot == "__auto__" else args.save_snapshot ) cmd_save_named_snapshot( base_url, auth_headers, snapshot_name, args.log_version, ) elif args.restore_snapshot is not None: snapshot_name = ( latest_snapshot_name() if args.restore_snapshot == "__latest__" else args.restore_snapshot ) cmd_restore_from_snapshot( base_url, auth_headers, snapshot_name, dry_run=args.dry_run, use_bulk=use_bulk, enable_only=args.enable_only, disable_only=args.disable_only, skip_snapshot=args.skip_snapshot, include_version=args.log_version, ) else: # Default: sync instance state to config 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( base_url, auth_headers, enable_names, disable_names, dry_run=args.dry_run, use_bulk=use_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()

      Jeremy Friedel
      Jeremy FriedelPosted 3 months ago • Last reply 3 months ago
      1
               
    …