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
    Plugins
      • 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
               
    • Question
      • Help and How-ToChevronRightIcon
                       
      Dashboard exports breaking layout when sent as PDF or Excel? Report Studio is built to fix that, without leaving Sisense
      Ido Darnell
      Ido Darnell
      Posted 3 weeks ago
      0
               
    • Blog banner
      • Widget & Dashboard ScriptsChevronRightIcon

      [Linux] Accessibility in Sisense Fusion with Scripts

                       

      Accessibility (often abbreviated as a11y ) is about building applications that everyone can use, including people who rely on assistive technologies such as screen readers, keyboard navigation, voice control, or high contrast themes. If you're new to accessibility or would like to learn more about how it applies to Sisense and Compose SDK applications, check out our previous article: Accessibility in Compose SDK This article focuses specifically on Fusion Highcharts widgets and shows how to enhance the accessibility features already available in Sisense using Widget Scripts, Dashboard Scripts, or Plugins. Accessibility in Fusion Widgets Some Fusion widgets uses Highcharts to render. Highcharts includes an extensive Accessibility Module that helps charts become usable for users with disabilities by providing: Keyboard navigation Screen reader support Automatic chart descriptions High contrast compatibility Sisense do not enable the Highcharts accessibility module by default, so you will need an additional configuration options. Which approach should I use? There are three different ways to apply this configuration depending on your needs. Widget Script, rely on one widget. Dashboard Script, rely on all widgets in a dashboard. Plugins Entire Fusion, or in selected dashboards ids. All three approaches can use exactly the same Highcharts configuration. The only difference is where the code is executed. Option 1: Widget Script If you only need to improve accessibility for a single visualization, open the widget editor and navigate to: Edit Widget → Script Then paste the following script. (function () { widget.on("beforeviewloaded", function (se, ev) { var options = ev.options; // This configuration can be reused for the others scripts options.accessibility = { enabled: true, landmarkVerbosity: "all", highContrastTheme: true, keyboardNavigation: { enabled: true, seriesNavigation: { mode: "normal", }, }, point: { describeNull: true, }, series: { describeSingleSeries: true, pointDescriptionEnabledThreshold: 200, }, screenReaderSection: { beforeChartFormat: "<{headingTagName}>{chartTitle}</{headingTagName}>" + "<div>{typeDescription}</div>" + "<div>{chartSubtitle}</div>" + "<div>{chartLongdesc}</div>" + "<div>{viewTableButton}</div>", }, }; if (options.title && !options.title.text && ev.widget && ev.widget.title) { options.title.text = ev.widget.title; } if (options.exporting) { options.exporting.accessibility = { enabled: true, }; } }); })(); Option 2: Dashboard Script To apply the same accessibility improvements to every widget in a dashboard, move the same logic into a Dashboard Script. /** * Chart Accessibility (Highcharts) for All Widgets * This script turns on the Highcharts accessibility module with a fuller set of * options (keyboard navigation, screen-reader descriptions, live-data * announcements) for every chart widget on the dashboard, instead of requiring * the script to be added widget-by-widget. * * Features: * - Applies to all chart-type widgets currently on the dashboard * - Automatically wires up widgets added to the dashboard afterwards * - Ensures a screen-reader-visible chart title even if the widget title is hidden * - Enables accessible exporting menu * * Before Implementation: * - Only widgets whose type starts with "chart/" are targeted (Highcharts-based * widgets). Pivot, indicator, and map widgets are skipped since the Highcharts * accessibility module does not apply to them. */ (function () { // Applies the accessibility options to a single widget's Highcharts config function applyAccessibility(widget) { // Only Highcharts-based widgets support the accessibility module if (!widget.type || !widget.type.startsWith("chart/")) return; widget.on("beforeviewloaded", function (se, ev) { var options = ev.options; options.accessibility = { enabled: true, // How many ARIA landmarks are exposed to screen readers ("all" | "one" | "disabled"). landmarkVerbosity: "all", // Respect the OS/browser high-contrast setting instead of the widget's own colors. highContrastTheme: true, keyboardNavigation: { enabled: true, seriesNavigation: { // Lets arrow keys move between individual points, not just series. mode: "normal", }, }, point: { // Fallback wording for null/empty data points instead of skipping them silently. describeNull: true, }, series: { describeSingleSeries: true, // Avoid announcing every single point on very large series (perf + noise). pointDescriptionEnabledThreshold: 200, }, screenReaderSection: { // Surface a "View as data table" link for screen-reader users. beforeChartFormat: "<{headingTagName}>{chartTitle}</{headingTagName}>" + "<div>{typeDescription}</div>" + "<div>{chartSubtitle}</div>" + "<div>{chartLongdesc}</div>" + "<div>{viewTableButton}</div>", }, }; // Screen readers read the chart title first — make sure one exists even if // the widget's own title is visually hidden. if (options.title && !options.title.text && ev.widget && ev.widget.title) { options.title.text = ev.widget.title; } if (options.exporting) { options.exporting.accessibility = { enabled: true }; } }); } // Event Listeners // Wire up every widget already on the dashboard when it first loads dashboard.on("initialized", function () { for (const widget of dashboard.widgets.$$widgets) { applyAccessibility(widget); } }); // Wire up widgets added to the dashboard afterwards (e.g. via "Add Widget") dashboard.widgets.on("widgetadded", function (se, ev) { applyAccessibility(ev.widget); }); })(); This approach avoids duplicating Widget Scripts while keeping the changes limited to a single dashboard. Option 3: Plugin If accessibility improvements should be available throughout your Sisense environment, the same configuration can be added to a plugin. Once installed, you can configurate with specific dashboards that you want to apply and every compatible Highcharts widget automatically receives the enhanced accessibility configuration. You can download found the plugin here . Understanding the configuration Although the script is relatively small, it enables several useful Highcharts accessibility features. Keyboard Navigation keyboardNavigation: { enabled: true, seriesNavigation: { mode: "normal" } } Allows users to navigate through individual chart points using only the keyboard, improving accessibility for users who cannot use a mouse. High Contrast Support highContrastTheme: true Automatically adapts the chart colors when users enable High Contrast Mode in their operating system. Screen Reader Improvements screenReaderSection: { beforeChartFormat: ... } Creates a richer description for screen readers by including: Chart Title Chart Description Subtitle Long Description View as Data Table button This gives users additional context before interacting with the visualization. Better Handling of Missing Data point: { describeNull: true } Instead of silently skipping empty values, screen readers describe null data points. Large Dataset Optimization pointDescriptionEnabledThreshold: 200 Reading every point in a chart containing hundreds or thousands of values quickly becomes overwhelming. This threshold limits detailed point descriptions for large datasets while maintaining overall chart accessibility. Single-Series Improvements describeSingleSeries: true Provides more meaningful descriptions for charts containing only one series. Automatic Widget Titles if (options.title && !options.title.text && ev.widget && ev.widget.title) { options.title.text = ev.widget.title; } Screen readers announce the chart title before reading its contents. If the Highcharts title is empty but the Sisense widget has a title, this code copies the widget title into the Highcharts configuration, ensuring users always hear a meaningful title. Export Accessibility options.exporting.accessibility = { enabled: true }; When exporting is enabled, exported charts retain accessibility metadata whenever supported. Testing Your Changes After applying the script, it's a good idea to verify the accessibility improvements. Some useful testing methods include: Navigating the chart using only the keyboard Running Lighthouse accessibility audits Testing with screen readers such as: NVDA VoiceOver Windows/Mac Narrator Accessibility testing should always include real keyboard navigation and screen reader testing whenever possible. Limitations This solution applies only to Highcharts-based widgets available in Sisense Linux. It does not affect: Pivot tables BloX widgets Custom React visualizations Compose SDK custom widgets Those components require their own accessibility implementation. Conclusion Accessibility is an ongoing process rather than a one-time configuration. While Sisense provides a option through Highcharts, taking advantage of the additional accessibility options available can help create dashboards that are more inclusive and easier to use. We encourage you to experiment with these settings, adapt them to your organization's needs, and share any additional accessibility improvements. References/Related Content  Compose SDK Accessibility Scripting in Sisense Plugins in Sisense Highcharts Accessibility Module Plugin Repository 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.

      Micael Santana
      Micael SantanaPosted 1 month ago
      0
               
    • Discussion
      Ido Darnell
      • Sisense Partners
               
      Ido Darnell
      Pixel-perfect design and reporting for Sisense, a review of available options
                                                                                                                                               

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

      1 month ago
      0
               
      • 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
               
    • Idea
      • Help and How-ToChevronRightIcon
      Your Sisense charts are clean, but are your tooltips telling the whole story?
                                                                                               
      Mia Isaacson
      Mia IsaacsonPosted 3 months ago
               
      0
               
    • Discussion
      Mia Isaacson
      • Help and How-To
               
      Mia Isaacson
      Tired of stacking indicator widgets? There's a better way.
                                                       

      If you've ever found yourself stacking indicator widget after indicator widget just to show a handful of KPIs, you're not alone. It's a common dashboard design (and performance) frustration, and it's exactly what the KPI Card from QBeeQ was built to solve. What is the KPI Card? The KPI Card is a free, partner-supported plugin from QBeeQ that provides an alternative to the native indicator widget, but with a lot more flexibility. Where Sisense's built-in indicator is limited to one primary and one secondary metric, the KPI Card lets you consolidate multiple metrics, trend indicators, and time-series sparklines into a single compact widget. The result is cleaner dashboards, less cognitive overload, and more context for your end users without adding more widgets. What it can do Feature Description Multiple Metrics Display primary and secondary values together in one widget Trend Indicators Up/down icons with conditional color formatting to show direction at a glance Sparklines Embed mini line, area, or bar charts directly in the widget — no separate chart needed Tooltips Hover over any sparkline point to surface deeper data instantly Click-to-Filter Click a data point or drag a range to filter your entire dashboard Jump-to-Dashboard Link directly to a related dashboard from a primary value or sparkline data point Full Color Customization Match your dashboard's branding with per-value color control and conditional formatting A few use cases where it shines Sales & revenue tracking — Show revenue, growth %, and a monthly trend sparkline in one card Operations dashboards — Monitor multiple performance metrics side-by-side without the clutter Finance reporting — Surface current period values alongside prior period comparisons and directional trends Embedded analytics — Give end users more context in less space, especially in tighter dashboard layouts Before and after The top row uses the native Sisense indicator chart and displays the primary and secondary metrics.  The bottom row uses the KPI Card to show the same metrics, plus additional context using sparklines, trend metrics, trend icons, and Jump to Dashboard It's free — part of the QBeeQ Starter PowerUp The KPI Card is included in QBeeQ's Starter PowerUp , a free bundle of 10 plugins built specifically for Sisense. It's available at no cost and is actively maintained and supported by the QBeeQ team. 👉 Get the free Starter PowerUp   🔗 KPI Card Marketplace Listing   📄 KPI Card Documentation   Have questions or want to share how you're using it? Drop a comment below! 

      4 months agolast reply 4 months ago
      1
               
      • Add-ons & Plug-InsChevronRightIcon

      The Smart Label

                                       

      Download:  Smart Label   Introduction The following article describes steps needed to add to create smart text widget to present ElastiCube fields as text.   Implementaion Steps Extract the folder in the attached zip file into \...\Sisense\PrismWeb\plugins. If you are using version 7.2 and higher unzip the contents into your  C:\Program Files\Sisense\app\plugins\  folder.  Create new widget and chose the smart label icon from the list Choose a field to present, set a design and hit "Apply" [Please note, that in case you have multiple values, it will present the first one. Therefore, it is always better to change the type of the filter to single value only, like in the given example] This plug in works on Version: L8.0.5.156. Only drop the plug in folder contained in the download folder into the plug ins folder in Sisense.  

      intapiuser
      intapiuserPosted 3 years ago • Last reply 9 months ago
      1
               
    • Blog banner
      • Use Case GalleryChevronRightIcon

      Week over week analysis with custom fiscal year: Use case of a fuel and convenience retail operator

                                                       

      Introduction Week-over-week (WoW) analysis is a key part of performance tracking for fast-moving, high-traffic businesses such as convenience stores, gas stations, and car washes.  For these organizations, aligning the fiscal calendar with operational cycles rather than the standard calendar year makes reporting more meaningful. In this use case, the fiscal year begins on the closest Sunday to January 1st, ensuring each year starts with a full week. This structure simplifies weekly reporting and keeps week-to-week comparisons consistent across years, which is important for tracking trends like fuel sales, store traffic, and service volumes. While nonstandard, this setup is commonly used in practice. What the Solution Does For standard, fixed calendar or fiscal years, week-over-week analysis can be achieved using the “First Month of Fiscal Calendar” and “First Day of Week” settings, along with the PASTYEAR function. However, for dynamic fiscal years that begin on a weekday closest to January 1st, these features don’t provide a usable solution, since the start date can fall in the previous or following calendar year. The solution uses the Filtered Measure certified add-on and a custom dashboard script to handle the custom fiscal year. Two year filters are added to the dashboard: one represents the selected fiscal year (user-selectable), and the other represents the prior year for comparison (locked and optionally hidden), which is automatically set with a dashboard script. The Filtered Measure plugin applies the selected-year filter to the measure for the chosen period, while the prior-year filter applies to the measure for the corresponding period in the previous year. This approach ensures that week-over-week calculations respect the custom fiscal calendar, providing accurate comparisons across equivalent weeks. Note: In this particular implementation, the fiscal years and week numbers are pre-calculated in the database and stored as numeric columns. To create a Date dimension table in your Elasticube with fiscal years starting on the first Sunday closest to January 1st, refer to the SQL example below. Why It’s Useful This solution addresses the native functional limitation by respecting the custom fiscal calendar, ensuring weekly trends are comparable across years. As a result, teams can reliably track key metrics, such as fuel sales, store traffic, and service volumes, on a true week-by-week basis, supporting better operational planning and more informed decision-making. Attachments WeekoverWeekAnalysiswithCustomFiscalYear.dash (dashboard) Sample ECommerce - Custom Fiscal Year.smodel (elasticube) JS Script - Automatic Update for Second Year Filter.txt (dashboard script) SQL Query - Dim Date with Custom Fiscal Year.txt (custom table SQL query) For the script to hide the second filter, refer to this BINextLevel article: Hide dashboard filters .

      Tri Anthony
      Tri AnthonyPosted 10 months ago
      0