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
      • Official Sisense Discord
    • Use Case Gallery
    Discussions
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   
    Discussions
    • TagsChevronRightIcon
    Admin: Configuration
    • 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 week 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 1 month ago • Last reply 1 month ago
      1
               
      • APIsChevronRightIcon

      Uploading Files Locally Using Rest (Linux)

               

      In Sisense Linux distribution you are able to upload files to the Sisense environment via the file management Interface. This article describes few ways for uploading files programmatically using bash, Powershell and python. Powershell Script for uploading a file via rest api for Sisense version greater than L2021.5 # ------- Config ------- # Sisense base DNS # For example http://23.50.45.130:30845 $baseUrl="<Sisense DNS>" # Destination location on sisense server - must be accessible via Sisense file management interface $remoteLocation="/test1/data.csv" # Local file path $filePath="data.csv" # admin api token $apiToken="<Admin API Token>" # Should the new file override existing files with the same name $shouldOverride="true" # ------- End Of Config ------- # File Management URL $uploadPathUrl="/app/explore/api/resources/data" # Create URL to send file to $url=$baseUrl+$uploadPathUrl+$remoteLocation + "?override=" + $shouldOverride #Write-Output $url # Create authorization bearer header $authorization="Bearer $apiToken" #Write-Output $authorization # In order to add files via the file explorer, one requires also x-auth token. the next part fetchs x-auth using the admin api token $xauthpath="/app/explore/api/login" $xauthurl=$baseUrl+$xauthpath #Write-Output $xauthurl $headers = @{ 'Authorization' = $authorization } # Send request to get xauth # For https remove -AllowUnencryptedAuthentication $xauth = Invoke-RestMethod -Uri $xauthurl -Method Post -Headers $headers -AllowUnencryptedAuthentication #Write-Output $xauth $headers += @{ 'X-Auth' = $xauth } # Send a request to upload a file # For https remove -AllowUnencryptedAuthentication $res = Invoke-RestMethod -Uri $url -Method Post -Headers $headers -AllowUnencryptedAuthentication -ContentType 'text/csv' -InFile $filePath # Print response Write-Output $res Bash Script for uploading a file via rest api (using cUrl) for Sisense version greater than L2021.5 #!/bin/bash # ------- Config ------- # Sisense base DNS # For example http://23.50.45.130:30845 baseUrl="<Sisense DNS>" # Destination location on sisense server - must be accessible via droppy remoteLocation="/test1/data.csv" # Local file path filePath="data.csv" # admin api token apiToken="<Admin API Token>" # Should the new file override existing files with the same name shouldOverride="true" # ------- End Of Config ------- # File Management URL uploadPathUrl="/app/explore/api/resources/data" # Create URL to send file to url=$baseUrl$uploadPathUrl$remoteLocation"?override="$shouldOverride echo $url # Create authorization bearer header authorization="Bearer $apiToken" # In order to add files via the file explorer, one requires also x-auth token. the next part fetchs x-auth using the admin api token xauthpath="/app/explore/api/login" xauthurl=$baseUrl$xauthpath echo $xauthurl # Send request to get xauth xauth=$(curl -H "Authorization: $authorization" $xauthurl) echo $xauth # Send a request to upload a file res=$(curl -H "Authorization: $authorization" -H "X-Auth: $xauth" -H "Content-Type: text/csv" -d @$filePath $url) # Print response echo $res Power-Shell 1 file upload Example - Version <=L20121.3 The script below will allow the user to upload files programmatically, using REST command. The script itself is written in Powershell. Change the first 4 parameters dns - The URL you use for your Sisense site RemoteLocation - The location of the target file within File Manager FilePath - The local location of the file from the Local Server AUTH_TOEN - Generate a token and replace it with <token> $dns= 'https://myhost.sisense.com' $RemoteLocation= 'data/Test' $FilePath = 'C:\myfolder\myfile.csv'; $AUTH_TOKEN = 'Bearer <Token>' ############################################################### $pos = $FilePath.LastIndexOf("\") $URL = $dns+'/app/explore/!/upload?vId=0&rename=0&to=/'+$RemoteLocation; $filename = $FilePath.Substring($pos+1) $fileBytes = [System.IO.File]::ReadAllBytes($FilePath); $fileEnc = [System.Text.Encoding]::GetEncoding('UTF-8').GetString($fileBytes); $boundary = [System.Guid]::NewGuid().ToString(); $LF = "`r`n"; $Headers = @{'Authorization'=$AUTH_TOKEN}; $bodyLines = ( "--$boundary", "Content-Disposition: form-data; name=`"filename`"; filename=`"$filename`"", "Content-Type: application/octet-stream$LF", $fileEnc, "--$boundary--$LF" ) -join $LF Invoke-RestMethod -Uri $URL -Method Post -ContentType "multipart/form-data; boundary=`"$boundary`"" -headers $Headers -Body $bodyLines Python Example For Folder Syncing (Upload Only) In this example you can set a local folder with sub-folders to sync with a File Management virtual library. First run will upload and update all files and create a lastRunTime.json file for reference of last run. On the next run only files that the update date is larger than the last run time of the application will be uploaded/updated Config.ini file sample: [DEFAULT] # Sisense URL host = https://test.sisense.com # Path to store files on target machine remoteLocation= data/Test # Path to folder or file which should be transferred # If folder is specifeed, all subfolders will be also proccessed path = C:\Users\Documents\Test\ # Sisense API token token = Bearer TOKEN # Script will store last modified time for each processed file. This will allow it to upload only modified files on next executions lastRunFilename = lastRunTime.json Droppy.py file sample: Droppy.py example Version <=L2021.3 import uuid import requests import io import json import os import time from datetime import datetime import configparser config = configparser.ConfigParser() config.read('config.ini') lastRunFilename = config.get('DEFAULT','lastRunFilename') host = config.get('DEFAULT','host') remoteLocation = config.get('DEFAULT','remoteLocation') path = config.get('DEFAULT','path') token = config.get('DEFAULT','token') # Cut slashes at the end of paths if they are exist if path[-1] == '\\':  path = path[:-1] if remoteLocation[-1] == '/':  remoteLocation = remoteLocation[:-1] # Function to compare stored and file last modified time def compareModificationTime(filePath):  if (filePath in lastRunTime and os.path.getmtime(filePath)>int(lastRunTime.get(filePath))) or (filePath not in lastRunTime):   return True  else:   return False # Function to upload file to Droppy via Sisense API def uploadFile (filesNames, folderPath):  if os.path.isfile(path):   globalFolderPath = os.path.dirname(path)  else:   globalFolderPath = path  if folderPath != globalFolderPath:   remotePath = remoteLocation + folderPath.replace(globalFolderPath,'')  else:   remotePath = remoteLocation  remotePath = remotePath.replace('\\','/')  url = host +'/app/explore/!/upload?vId=0&rename=0&to=/'+remotePath  files = []  header = {'Authorization': token}  notSentFiles = []  for filename in filesNames:   filePath = '%s\\%s'%(folderPath, filename)   if compareModificationTime(filePath):    files.append( ("file", ( open(filePath, "rb"))))    lastRunTime [filePath] = int(time.time())   else:    notSentFiles.append(filePath)  if files:      r = requests.post(url, files=files, headers=header)   for filename in filesNames:    print(remotePath+'/'+filename, r.status_code, r.reason)  return notSentFiles try:  f = open(lastRunFilename, "r")  timestamp = f.read()  lastRunTime = json.loads(timestamp) except:  lastRunTime = {} notSentFiles=[] if ( os.path.isdir(path)):  for root, subdirs, files in os.walk(path):   if files:    notSentFiles += uploadFile (files, root)    time.sleep(0.1) elif ( os.path.isfile(path)):  notSentFiles += uploadFile ([os.path.basename(path)], path.rsplit('\\', 1)[0])   elif not os.path.exists(path):  print ("Specified path doesn't exist") if notSentFiles:  print ('These files were not uploaded because they were not modified:')  for file in notSentFiles:   print (file) with open(lastRunFilename, 'w') as f:     json.dump(lastRunTime, f) Version >= L2021.5 import uuid import requests import io import json import os import time from datetime import datetime import configparser ​ ​ config = configparser.ConfigParser() config.read('config.ini') ​ lastRunFilename = config.get('DEFAULT','lastRunFilename') host = config.get('DEFAULT','host') remoteLocation = config.get('DEFAULT','remoteLocation') path = config.get('DEFAULT','path') token = config.get('DEFAULT','token') ​ # Cut slashes at the end of paths if they are exist if path[-1] == '\\': path = path[:-1] if remoteLocation[-1] == '/': remoteLocation = remoteLocation[:-1] ​ # Function to compare stored and file last modified time def compareModificationTime(filePath): ​ if (filePath in lastRunTime and os.path.getmtime(filePath)>int(lastRunTime.get(filePath))) or (filePath not in lastRunTime): return True else: return False ​ # Function to upload file to Droppy via Sisense API def uploadFile (filesNames, folderPath): def get_auth(token): headers = { 'Authorization': token, } ​ ​ x_auth = requests.post(f'{host}/app/explore/api/login', headers=headers) if x_auth.status_code==200: return x_auth.text else: print ('Cannot get x-auth token. Check API token validity') return False # # SERVICE FUNCTION # # Do NOT modify x_auth = get_auth(token) if os.path.isfile(path): globalFolderPath = os.path.dirname(path) else: globalFolderPath = path ​ if folderPath != globalFolderPath: remotePath = remoteLocation + folderPath.replace(globalFolderPath,'') else: remotePath = remoteLocation ​ remotePath = remotePath.replace('\\','/') ​ files = [] header = {'Authorization': token, 'x-auth': x_auth,} notSentFiles = [] for filename in filesNames: filePath = '%s\\%s'%(folderPath, filename) if compareModificationTime(filePath): files.append(filePath) lastRunTime [filePath] = int(time.time()) else: notSentFiles.append(filePath) if files: for file in files: with open(file,'rb') as ff: file=os.path.basename(file) r = requests.post(f'{host}/app/explore/api/resources/{remotePath}/{file}?override=true', headers=header,data=ff) print(remotePath+'/'+file, r.status_code, r.reason) ​ return notSentFiles ​ ​ try: f = open(lastRunFilename, "r") timestamp = f.read() lastRunTime = json.loads(timestamp) except: lastRunTime = {} ​ ​ ​ notSentFiles=[] if ( os.path.isdir(path)): ​ for root, subdirs, files in os.walk(path): if files: ​ notSentFiles += uploadFile (files, root) ​ time.sleep(0.1) elif ( os.path.isfile(path)): ​ notSentFiles += uploadFile ([os.path.basename(path)], path.rsplit('\\', 1)[0]) ​ elif not os.path.exists(path): print ("Specified path doesn't exist") ​ if notSentFiles: print ('These files were not uploaded because they were not modified:') for file in notSentFiles: print (file) ​ ​ with open(lastRunFilename, 'w') as f: json.dump(lastRunTime, f) File Management (Upload, Delete, Download, Rename Etc)

      intapiuser
      intapiuserPosted 3 years ago
      0
               
      • Sisense AdministrationChevronRightIcon

      DBfarm Cleaner, remove unattached cubes

                               

      When a build fails, the folder that was created up to that point with the data will still remain on your server. This can consume 100s of GB and required to clean from time to time. The attached application runs on the server cleaning all unnecessary folders. Steps for running the app -  1. Download the attached exe file 2. Run it as with the selected parameter <span>elasticubedatacleaner <command> [options]<br/></span> Commands   list List folders to delete without deletion. delete Delete unused folders. Options   -h, --help Show help. --ec data folders [EC DATA FOLDERS] List of folders directory to scan. --ec service name [EC SERVICE NAME] Specify the name of the EC service. Default: Sisense.ECMS (for version 7.1 and earlier change the service to ElastiCubeManager --delete_alternative Delete Alternative folders. An alternative is created for a cube building in an accumulated mode in order to be used as a base cube once the second cube started

      intapiuser
      intapiuserPosted 3 years ago
      0
               
      • Sisense AdministrationChevronRightIcon

      Custom Domain For Managed Services - Re-Branding the URL

               

      In some cases, a customer would like to have his own domain instead of the *.sisense.com provided by the Sisense team.  If you wish to use your own domain please follow the steps below: Windows Generate a PFX file of your domain. Open a ticket  including the requested domain and transfer the PFX file to the customer data FTPS folder including a txt file with a password. Sisense Agent will validate the PFX file. If the PFX file is valid, the Sisense Agent will reach out to schedule a time to switch the domain. At the time of the switch, you will need to add an Aname record on your domain controller pointing the new domain name to your designated Sisense machine IP.  Changing the domain may take a few minutes to update so you may want to declare downtime during the process. Linux Sisense will request a free certificate via ACM (Amazon Certificate Management) service which generates a certificate on your approval via your DNS. Please read the documentation with details from Amazon if you need more details:   https://docs.aws.amazon.com/acm/latest/userguide/dns-validation.html In this scenario, the Customer doesn't need to provide us with certificate files if they are an owner of a custom DNS. This certificate will be auto-prolonged if the CNAME records will be kept in DNS. Open a ticket   including the requested domain and ask to move it to the Cloud Ops Team. The agent will schedule the time of the DNS change.

      intapiuser
      intapiuserPosted 3 years ago
      0
               
      • Sisense AdministrationChevronRightIcon

      Modifying Email Report URLs

                               

      Introduction When embedding Sisense in a parent application, there will be a need to route email reports to your parent application dashboard URL. For example, when a customer will load a dashboard in the embedded application, the dashboard URL might look something like this https://endcustomer.appowner.com/division/analytics?sisense_url=%2Fdashboards%2F{dashboard_oid} While the original Sisense dashboard URL might looks something like this: http://endcustomer-bi.appowner.com/app/main%23/dashboards/{dashboard_oid} In this case, when a customer receives an email report from the application, for instance to view a dashboard that was shared with them, they will be routed to the original Sisense dashboard URL and not the parent application URL.  Solutions This article proposes two solutions to this: 1) Change the Alias field in the Admin/Settings of Sisense to the parent application URL. If the parent application dashboard URLs match this pattern: http://subdomain.domain.com/app/main%23/dashboards/{dashboard_oid } Then this solution will work. But, in case the parent application dashboard URL must be manipulated, you will need to apply the second solution.   2) Manipulate the embedded  url token in Sisense templates . Let's use the dashboard share report as an example. If we open: path/to/email/templates/dashboard_report/html.ejs We will the see following: ... <td align="left" bgcolor="#ffffff"> <a name='dashboard' href='<%= url %>' target='_blank'> <img src='cid:<%= images[i] %>' id="<%= images[i] %>" name="<%= images[i] %>" /> </a> </td> We notice that the anchor tag has an EJS expression as the reference for the hyperlink: href='<%= url %>' The url token in this case evaluates to the original Sisense dashboard URL: url = http://endcustomer-bi.appowner.com/app/main%23/dashboards/{dashboard_oid} We can use JavaScript replace method to manipulate this token to match the parent application dashboard URL as so: <a name='dashboard' href='<%= url.replace('http://endcustomer-bi.appowner.com/app/main%23/dashboards/', 'https://endcustomer.appowner.com/division/analytics?sisense_url=%2Fdashboards%2F') %>' ... </a> When the email will be generated, the URL will resolve to the expected endpoint: https://endcustomer.appowner.com/division/analytics?sisense_url=%2Fdashboards%2F{dashboard_oid} and direct the customer to the dashboard in the parent application.   * This solution was tested on Linux LA version 7.4.1.571 and Windows version 7.4.

      intapiuser
      intapiuserPosted 3 years ago
      0
               
      • Sisense AdministrationChevronRightIcon

      Cancel IIS Timeout And Recycling

               

      The IIS has default settings for Timeout and Recycling, which are used for the following reasons: Timeout : One way to conserve system resources is to configure idle time-out settings for the worker processes in an application pool. When these settings are configured, a worker process will shut down after a specified period of inactivity. The default value for idle time-out is 20 minutes. Idle time-out can be helpful in the following situations: The server has a heavy processing load. Specific worker processes are consistently idle. No new processing space is available. Recycling : Worker process isolation mode offers process recycling, in which IIS automatically refreshes Web applications by restarting their worker processes. Process recycling keeps problematic applications running smoothly, and is an especially effective solution in cases where it is not possible to modify the application code. However, these functionalities might cause some problems, such as scheduled email reports which are inconsistently not sent. In order to cancel IIS Timeout and recycling, follow these steps: Cancel Idle Time-out: Go into the IIS Manager Click on Application Pools (on the left) Right click on sisense application  Select "Set Application Pool Defaults..." Change the value of "Idle Time-out (minutes)" from 20 to 0 Click "ok" Cancel IIS Recycling : Go into the IIS Manager Click on Application Pools (on the left) Right click on sisense application Select "Recycling..." Uncheck "Regular time intervals (in minutes)"  Click next Click finish Restart the IIS

      intapiuser
      intapiuserPosted 3 years ago
      0
               
      • Sisense AdministrationChevronRightIcon

      Identifying Sisense Services

                               

      This article is a reference point for understanding Sisense services and what their purposes are. The notes column contains supplemental information about the service. Please note that this is not an exhaustive list of how you would interact with the services for each scenario. Current List of Sisense Services (as of Version 7.2) Service Name Name Purpose Notes          Sisense.Broker Broker Responsible for communication between services. All micro-services utilize the Broker service. In High Availability deployments, this service is associated with RabbitMQ as well. Ensure this service is running as it is a prerequisite for other Sisense services to function.  Sisense.CLRConnectorsContainer  CLR Connectors Responsible for powering CLR (.NET) connectors. You may restart this service if you're having trouble with particular CLR-based connectors.  Sisense.Collector Collector - Sisense Internal Monitoring Responsible for internal monitoring and collection of data about the machine and process performance. This service must be running in order to send logs for Sisense monitoring.  Sisense.Configuration Configuration Manager Responsible for powering the Configuration Manager application located at http://localhost:3030  on your Sisense webserver. Also exposes APIs for other configurations. You may restart this service if you find you're having trouble getting the Configuration Manager site to load.  Sisense.Discovery Discovery Responsible for saving and storing configuration changes including those made in the Configuration Manager. Typically you will leave this service alone outside of a Sisense migration.  Sisense.ECMLogs ElastiCube Manager Logging Responsible for storing build logs from ECM 1.0 and ECM 2.0, PSM, APIS, etc. There should not be any reason for starting this service outside of basic configuration changes (Sisense migration, MongoDB changes, etc).  Sisense.ECMS ECM 1.0 (Desktop ElastiCube Manager) Responsible for powering the Desktop ECM. You may restart this service if you're seeing issues building or accessing ElastiCubes on the desktop.  Sisense.ECMServer ECM 2.0 (Web ElastiCube Manager) Responsible for powering the Web ECM. You may restart this service if you're seeing issues building or accessing ElastiCubes within Sisense Web.  Sisense.Galaxy Galaxy Controls the majority of web server logic. At this time this service also controls: Emails Reporting Export to image You may choose to restart this service in the following example cases: Many API calls that failed with "api not found". User can log in but not able to navigate through dashboards upon login. Emails are not going out.  Sisense.Gateway Gateway Main entry point of the Sisense system. This service routes the API between many of the microservices. By default, this services listens on port 8081 and can be changed within the Configuration Manager. You may restart this service if you are having trouble reaching the Sisense site on your designated port.  Sisense.HouseKeeper HouseKeeper This service is responsible for memory management of the Sisense application. Typically there is not a need to restart or modify this service.  Sisense.Identity Identity Responsible for Sisense groups, roles, users, etc. This service is responsible for authentication and user management. You may restart this service in instances where your user is unable to log in due to errors in authentication (not related to SSO authentication).  Sisense.Jobs Jobs Responsible for triggering jobs including emails after a cube build, emailed reports on a schedule, etc. Typically there is not a need to restart or modify this service outside of troubleshooting scheduled jobs.  Sisense.JVMConnectorsContainer JVM Connectors Responsible for powering JVM connectors. You may restart this service if you're having trouble with particular JVM-based connectors.  Sisense.Orchestrator Orchestration Utilized in HA to synchronize and distribute built ElastiCubes on the build node to the query nodes. You may restart this service changing HA configuration settings. Please consult the HA documentation before restarting this service.  Sisense.Oxygen Oxygen (Licensing) Responsible for communicating with Sisense to verify licensing You may need to restart this server if you are having licensing issues within the ECM or Sisense Web. Must be restarted upon license update (along with a logout and log back in).  Sisense.Plugins Plugins Management Node.js service which builds plugins. Users do not need to restart this service each time they change plugins. Plugins service watches the plugins folder for changes and runs a build mechanism automatically.  Sisense.QueryProxy Query Services Provides a unified way to translate and access the backend SQL Engines. Responsible for query executions, throttling, load balancing, versioning, streaming and results formatting. This service must be running in order for query execution to work.  Sisense.Repository Repository Responsible for storing Sisense metadata including: Dashboards Users Data security This service maintains connection to the files contained in MongoDB This service must be running in order for users to view dashboards and log into Sisense Web.  Sisense.Shipper Shipper Responsible for shipping logs to Sisense for monitoring.  Reads data from C:\ProgramData\Sisense\Monitoring\LOGS folder and ships it. This service must be running in order to send logs for Sisense monitoring.  Sisense.SPE Data Prep and Streaming Engine Responsible for powering: Export to Excel Preview table Data Wizard Custom tables and custom columns You should restart this service when changing configuration settings for any of the modules power by it.  Sisense.StorageManager Storage Manager Responsible for storing files for file-based connectors such as CSV and Excel files Typically no need to restart or change this service. You should restart this service when changing configuration for this component such as changing the max file size or minimum hard disk space for uploading files..  Sisense.Usage Usage Service This service is responsible for saving off Sisense usage data. Typically no need to restart or change this service. You may restart this service after a change in configuration of the Usage feature. Notable services on Sisense Version 7.1 and below: Service Name Name Purpose Notes          ElasticubeManagmentService ElastiCube Management Responsible for powering the Desktop ECM. This service was replaced by Sisense.ECMS and Sisense.ECMServer. You may restart this service if you're seeing issues building or accessing ElastiCubes on the desktop.  Sisense.ECMLogsPersistenceService  ECM Logs Responsible for storing build logs from the Desktop ECM. This service was replaced by Sisense.ECMLogs. There should not be any reason for starting this service outside of basic configuration changes (Sisense migration, MongoDB changes, etc).  Sisense.Pulse Pulse Responsible for powering pulse alerts. This service was replaced by Sisense.Broker. You may restart this service if you are experiencing issues with Pulse across the board.

      intapiuser
      intapiuserPosted 3 years ago
      0
               
      • How-Tos & FAQsChevronRightIcon

      How to Export a Certificate using the Certificate Export Wizard (Windows only)

                               

      How to Export a Certificate using the Certificate Export Wizard In Sisense version 7.2 and later, SSL (Secure Sockets Layer) settings have moved from Microsoft IIS into the Sisense Configuration Manager. For organizations moving to Sisense 7.2 and above from an earlier version that would like to continue using SSL, it is necessary to obtain the certificate to make SSL work. See the steps below to attempt to obtain the certificate from the server's certificate store if your organization previously had SSL set up on the Sisense web server. Organizations can also opt to contact their Certificate Authority (CA) to obtain an up-to-date copy of your site's .pfx or .crt and .key files. This explanation utilizes mmc (Microsoft Management Console) to export the certificate. On your webserver, search programs for mmc and select mmc.exe In the window that appears, click on "Add/Remove Snap-in..."   In the "Available snap-ins" column, click on Certificates then click "Add >" to add it to the "Selected snap-ins" column In the resulting pop-up, select "Computer account" then click 'Next'   Leave the settings as is on the next screen and click Finish   Click on OK to add the Certificate snap-in     On the left-hand side of the screen, expand Certificate (Local Computer) > Personal > Certificates   Right-click on your certificate, go to All Tasks > Export   The Certificate Export Wizard will appear which will assist you in exporting your organization's certificate to the appropriate format. Select the Next button.  On the Export Private Key screen, select "Yes, export the private key". The private key is necessary for SSL to work in Sisense 7.2 and higher. Then select the Next button.   On the Export File Format screen, select the Personal Information Exchange (.PFX) file option and hit the check boxes that correspond to the options below, then select the Next button.     ☐ Include all certificates in the certification path if possible     ☐ Export all extended properties     ☐ Enable certificate privacy   On the Security screen, select the Password checkbox and enter a password. Make sure the encryption setting is TripleDES-SHA1. Then select the Next button. Select the file location in which to save the exported certificate. Then select the Next Button.   On the last screen select the Finish button to complete the setup. Complete the setup in this guide to set up SSL in Sisense.

      intapiuser
      intapiuserPosted 3 years ago
      0
               
      • Sisense AdministrationChevronRightIcon

      Automatically Backup Sisense Web Data

               

      Sisense uses MongoDB for storing your Sisense web environment information. This information includes how dashboard and widgets are configured, your system's users and groups, and many other configurations. Since the web environment can regularly change, and may need to be restored, it's useful to keep regular backups of this data. This article shows how to use a program that will perform daily backups of this data: **This article is only relevant Sisense Windows versions 6.7 and above.  For Sisense Linux versions, please see Backing Up and Restoring Sisense   Download the attached .bat files The  MongoBackup.bat  file is used for backing up your MongoDB, and the  MongoRestore.bat  file is used in case you need to restore the backed up information. You can find both of these files here: MongoBackup.bat MongoRestore.bat Create a WriteUser In order to be able to use these scripts, you need to have a WriteUser to your MongoDB. For more information on how to do so, check out our  documentation . Change the credentials You can open each of these files in notepad. It will look like this: Change the credentials to those you've just created. Schedule the script The only script you should schedule is the  MongoBackup.bat,  Since you'll only use  MongoRestore.bat  in case you need to restore your data. The easiest way to do so is to use the Windows program Task Scheduler. Information on how to schedule a task can be found here:  https://support.microsoft.com/en-us/office/create-edit-delete-and-restore-tasks-30346281-30d4-4d6b-a6fa-55beca8d38a3

      intapiuser
      intapiuserPosted 3 years ago
      0