Package s3 :: Module s3dashboard
[frames] | no frames]

Source Code for Module s3.s3dashboard

   1  # -*- coding: utf-8 -*- 
   2   
   3  """ Dashboards 
   4   
   5      @copyright: 2016-2019 (c) Sahana Software Foundation 
   6      @license: MIT 
   7   
   8      @requires: U{B{I{gluon}} <http://web2py.com>} 
   9   
  10      Permission is hereby granted, free of charge, to any person 
  11      obtaining a copy of this software and associated documentation 
  12      files (the "Software"), to deal in the Software without 
  13      restriction, including without limitation the rights to use, 
  14      copy, modify, merge, publish, distribute, sublicense, and/or sell 
  15      copies of the Software, and to permit persons to whom the 
  16      Software is furnished to do so, subject to the following 
  17      conditions: 
  18   
  19      The above copyright notice and this permission notice shall be 
  20      included in all copies or substantial portions of the Software. 
  21   
  22      THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 
  23      EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 
  24      OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 
  25      NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 
  26      HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 
  27      WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 
  28      FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 
  29      OTHER DEALINGS IN THE SOFTWARE. 
  30   
  31      @status: experimental, work in progress 
  32  """ 
  33   
  34  __all__ = ("S3Dashboard", 
  35             "S3DashboardConfig", 
  36             "S3DashboardWidget", 
  37             ) 
  38   
  39  import json 
  40  import uuid 
  41   
  42  from gluon import * 
  43   
  44  from s3utils import s3_get_extension, s3_str 
  45  from s3widgets import ICON 
  46  from s3validators import JSONERRORS 
  47   
  48  DEFAULT = lambda: None 
  49  DEFAULT_FORMAT = "html" 
50 51 # ============================================================================= 52 -class S3DashboardContext(object):
53 """ Context data for a dashboard """ 54
55 - def __init__(self, r=None, dashboard=None):
56 """ 57 Constructor 58 59 @param r: the request object (defaults to current.request) 60 @param dashboard: the dashboard (S3Dashboard) 61 """ 62 63 # @todo: add request owner info (to select the active config) 64 65 # Context variables 66 self.dashboard = dashboard 67 self.shared = {} 68 69 # Global filters 70 # @todo: implement 71 self.filters = {} 72 73 # Parse request info 74 self._parse()
75 76 # -------------------------------------------------------------------------
77 - def error(self, status, message, _next=None):
78 """ 79 Action upon error 80 81 @param status: HTTP status code 82 @param message: the error message 83 @param _next: destination URL for redirection upon error 84 (defaults to the index page of the module) 85 """ 86 87 if self.representation == "html": 88 89 current.session.error = message 90 91 if _next is None: 92 _next = URL(f="index") 93 redirect(_next) 94 95 else: 96 97 current.log.error(message) 98 99 if self.representation == "popup": 100 # Display human-readable error message 101 headers = {} 102 body = DIV(message, _style="color:red") 103 else: 104 headers = {"Content-Type":"application/json"} 105 body = current.xml.json_message(success=False, 106 statuscode=status, 107 message=message, 108 ) 109 raise HTTP(status, body=body, web2py_error=message, **headers)
110 111 # ------------------------------------------------------------------------- 112 # Getters and setters for shared context variables 113 # 114 # Widgets and callbacks can share data in the context, e.g. when they 115 # use common object instances, or need to coordinate identifiers, like: 116 # 117 # - set the value for a variable: 118 # >>> context["key"] = value 119 # 120 # - get the value of a variable: 121 # >>> value = context["key"] 122 # >>> value = context.get("key", "some_default_value") 123 # 124 # - check for a variable: 125 # >>> if key in context: 126 # 127 # - remove a variable: 128 # >>> del context["key"] 129 #
130 - def get(self, key, default=None):
131 132 return self.shared.get(key, default)
133
134 - def __getitem__(self, key):
135 136 return self.shared[key]
137
138 - def __setitem__(self, key, value):
139 140 self.shared[key] = value
141
142 - def __delitem__(self, key):
143 144 del self.shared[key]
145
146 - def __contains__(self, key):
147 148 return key in self.shared
149 150 # -------------------------------------------------------------------------
151 - def __getattr__(self, key):
152 """ 153 Called upon context.<key> - looks up the value for the <key> 154 attribute. Falls back to current.request if the attribute is 155 not defined in this context. 156 157 @param key: the key to lookup 158 """ 159 160 if key in self.__dict__: 161 return self.__dict__[key] 162 163 sentinel = object() 164 value = getattr(current.request, key, sentinel) 165 if value is sentinel: 166 raise AttributeError 167 return value
168 169 # -------------------------------------------------------------------------
170 - def _parse(self, r=None):
171 """ 172 Parse the request info 173 174 @param r: the web2py Request, falls back to current.request 175 """ 176 177 request = current.request if r is None else r 178 179 args = request.args 180 get_vars = request.get_vars 181 182 command = None 183 if len(args) > 0: 184 command = args[0] 185 if "." in command: 186 command = command.split(".", 1)[0] 187 if command: 188 self.command = command 189 190 # Dashboard controller can explicitly mark requests as bulk 191 # even with a single agent ID (e.g. if the dashboard happens 192 # to only have one widget) - either by appending a comma to 193 # the agent ID, or by specifying ?bulk=1 194 bulk = get_vars.get("bulk") 195 bulk = True if bulk and bulk.lower() in ("1", "true") else False 196 197 agent_id = request.get_vars.get("agent") 198 if agent_id: 199 if type(agent_id) is list: 200 agent_id = ",".join(agent_id) 201 if "," in agent_id: 202 bulk = True 203 agent_id = set([s.strip() for s in agent_id.split(",")]) 204 agent_id.discard("") 205 agent_id = list(agent_id) 206 elif bulk: 207 agent_id = [agent_id] 208 self.agent = agent_id 209 210 self.bulk = bulk 211 212 self.http = current.request.env.request_method or "GET" 213 self.representation = s3_get_extension(request) or DEFAULT_FORMAT
214
215 # ============================================================================= 216 -class S3DashboardConfig(object):
217 """ 218 Dashboard Configuration 219 """ 220 221 DEFAULT_LAYOUT = "boxes" 222
223 - def __init__(self, 224 layout, 225 widgets=None, 226 default=None, 227 configurable=False, 228 ):
229 """ 230 Constructor 231 232 @param layout: the layout, or the config dict 233 @param widgets: the available widgets as dict {name: widget} 234 @param default: the default configuration (=list of widget configs) 235 @param configurable: whether this dashboard is user-configurable 236 """ 237 238 if isinstance(layout, dict): 239 config = layout 240 title = config.get("title", current.T("Dashboard")) 241 layout = config.get("layout") 242 widgets = config.get("widgets", widgets) 243 default = config.get("default", default) 244 configurable = config.get("configurable", configurable) 245 246 # Configuration ID 247 # - None means the hardcoded default 248 self.config_id = None 249 250 # Page Title 251 self.title = title 252 253 # Layout 254 if layout is None: 255 layout = self.DEFAULT_LAYOUT 256 self.layout = layout 257 258 # Available Widgets 259 if not widgets: 260 widgets = {} 261 self.available_widgets = widgets 262 263 # Active Widgets 264 if not default: 265 default = [] 266 self.active_widgets = default 267 268 self.version = None 269 self.next_id = 0 270 271 # Is this dashboard user-configurable? 272 self.configurable = configurable 273 self.loaded = True if not configurable else False
274 275 # -------------------------------------------------------------------------
276 - def load(self, context):
277 """ 278 Load the current active configuration for the context 279 280 @param context: the current S3DashboardContext 281 """ 282 283 if not self.configurable: 284 return 285 286 table = current.s3db.s3_dashboard 287 query = (table.controller == context.controller) & \ 288 (table.function == context.function) & \ 289 (table.active == True) & \ 290 (table.deleted != True) 291 row = current.db(query).select(table.id, 292 table.layout, 293 table.title, 294 table.version, 295 table.next_id, 296 table.widgets, 297 limitby = (0, 1), 298 ).first() 299 300 if row: 301 # Version key and next widget ID 302 self.version = row.version 303 if row.next_id: 304 self.next_id = row.next_id 305 306 # Layout and title 307 self.layout = row.layout 308 if row.title: 309 self.title = row.title 310 311 # Active widgets 312 widgets = row.widgets 313 if type(widgets) is list: 314 self.active_widgets = widgets 315 316 # Store record ID 317 self.config_id = row.id 318 319 self.loaded = True
320 321 # -------------------------------------------------------------------------
322 - def save(self, context, update=None):
323 """ 324 Save this configuration in the database 325 326 @param context: the current S3DashboardContext 327 @param update: widget configurations to update, as dict 328 {widget_id: {config-dict}} 329 330 @return: the new version key, or None if not successful 331 """ 332 333 # Must be configurable and loaded 334 if not self.configurable or not self.loaded: 335 return None 336 337 db = current.db 338 table = current.s3db.s3_dashboard 339 340 # Collect the widget configs 341 widgets = self.active_widgets 342 343 # Updated widget configs 344 configs = [] 345 for widget in widgets: 346 widget_id = widget.get("widget_id") 347 new_config = update.get(widget_id) 348 if new_config: 349 new_config["widget_id"] = widget_id 350 configs.append(new_config) 351 else: 352 configs.append(widget) 353 354 # Generate a new version key 355 version = uuid.uuid4().get_hex() 356 357 config_id = self.config_id 358 if not config_id: 359 # Create new record 360 data = {"controller": context.controller, 361 "function": context.function, 362 "version": version, 363 "next_id": self.next_id, 364 "active": True, 365 "widgets": configs, 366 } 367 config_id = table.insert(**data) 368 if not config_id: 369 version = None 370 # @todo: call onaccept? 371 else: 372 # Update existing record 373 data = {"version": version, 374 "next_id": self.next_id, 375 "widgets": configs, 376 } 377 success = db(table.id == config_id).update(**data) 378 if not success: 379 version = None 380 # @todo: call onaccept? 381 382 if version: 383 self.version = version 384 385 return version
386
387 # ============================================================================= 388 -class S3DashboardChannel(object):
389 """ 390 A dashboard channel 391 (=a section of the dashboard where widgets can be added) 392 """ 393
394 - def __init__(self):
395 """ 396 Constructor 397 """ 398 399 self.widgets = {}
400 401 # -------------------------------------------------------------------------
402 - def add_widget(self, widget, position=None):
403 """ 404 Add XML for a widget to this channel 405 406 @param widget: the widget XML (e.g. DIV instance) 407 @param position: the position of the widget in the channel, 408 if there are multiple widgets in the channel 409 """ 410 411 widgets = self.widgets 412 if position not in widgets: 413 widgets[position] = [widget] 414 else: 415 widgets[position].append(widget)
416 417 # -------------------------------------------------------------------------
418 - def __iter__(self):
419 """ 420 Iterate over the widgets in this channel, in order of their 421 positions; used in layouts to build the channel contents. 422 423 @note: Widgets without explicit position (=None), or multiple 424 widgets at the same position, will be returned in the 425 order in which they have been added to the channel. 426 """ 427 428 widgets = self.widgets 429 positions = sorted(p for p in widgets if p is not None) 430 431 if None in widgets: 432 positions.append(None) 433 434 for position in positions: 435 widget_list = widgets[position] 436 for widget in widget_list: 437 yield(widget)
438 439 # -------------------------------------------------------------------------
440 - def __len__(self):
441 """ 442 Number of widgets in this channel, useful for sizing of 443 container elements in layouts: 444 445 - number_of_widgets = len(channel) 446 """ 447 448 total = sum(len(widgets) for widgets in self.widgets.values()) 449 return total
450
451 # ============================================================================= 452 -class S3DashboardLayout(object):
453 """ 454 Base class for dashboard layouts, can be subclassed 455 to implement custom layouts 456 """ 457 458 # Tuple of available channels 459 # - leave at None in subclasses that dynamically generate channels 460 CHANNELS = None 461 462 # The default channel 463 DEFAULT_CHANNEL = None 464 465 # ------------------------------------------------------------------------- 466 # Methods to be implemented in subclasses 467 # -------------------------------------------------------------------------
468 - def build(self, context):
469 """ 470 Build the layout with the contents added by agents 471 - to be implemented in subclasses 472 473 @param context: the current S3DashboardContext 474 475 @return: the dashboard contents, usually a TAG instance, 476 alternatively a dict for the view (for custom 477 views) 478 479 @note: can override current.response.view to use a 480 specific view template (default is dashboard.html) 481 """ 482 483 return ""
484 485 # -------------------------------------------------------------------------
486 - def add_widget(self, widget, channel=DEFAULT, position=None):
487 """ 488 Add contents to layout, 489 - can be overwritten in subclasses (e.g. to dynamically 490 create channels) 491 492 @param contents: the contents to insert 493 @param channel: the channel where to insert the contents, 494 using the default channel if None 495 @param position: the position within the channel (numeric), 496 append to channel if None 497 """ 498 499 # Fall back to default channel 500 if channel is DEFAULT: 501 channel = self.DEFAULT_CHANNEL 502 503 # Get the channel 504 # - subclasses may want to dynamically generate channels here 505 channel_ = self.channels.get(channel) 506 507 # Add widget to channel 508 if channel_ is not None: 509 channel_.add_widget(widget, position=position)
510 511 # ------------------------------------------------------------------------- 512 # Helpers 513 # -------------------------------------------------------------------------
514 - def build_channel(self, key, **attr):
515 """ 516 Build a single channel, usually called by build() 517 518 @param key: the channel key 519 @param attr: HTML attributes for the channel 520 521 @return: the XML for the channel, usually a DIV instance 522 """ 523 524 widgets = default = XML("&nbsp;") 525 526 channel = self.channels.get(key) 527 if channel is not None: 528 widgets = [w for w in channel] 529 if not widgets: 530 widgets = default 531 532 return DIV(widgets, **attr)
533 534 # ------------------------------------------------------------------------- 535 # Base class methods 536 # -------------------------------------------------------------------------
537 - def __init__(self, config):
538 """ 539 Constructor 540 541 @param config: the active S3DashboardConfig 542 """ 543 544 self.config = config 545 546 # Set up channels 547 CHANNELS = self.CHANNELS 548 if CHANNELS: 549 self.channels = dict((name, S3DashboardChannel()) for name in CHANNELS) 550 else: 551 self.channels = {}
552
553 # ============================================================================= 554 -class S3DashboardBoxesLayout(S3DashboardLayout):
555 """ 556 Simple 5-boxes layout for dashboards 557 558 +--------------------+ 559 | N | 560 +----+----------+----+ 561 | W | C | E | 562 +----+----------+----+ 563 | S | 564 +--------------------+ 565 """ 566 567 # Tuple of available channels 568 CHANNELS = ("N", "W", "C", "E", "S") 569 570 # The default channel 571 DEFAULT_CHANNEL = "C" 572 573 # -------------------------------------------------------------------------
574 - def build(self, context):
575 """ 576 Build the layout with the contents added by agents 577 578 @param context: the current S3DashboardContext 579 580 @return: the dashboard contents (TAG) 581 """ 582 583 T = current.T 584 585 channel = self.build_channel 586 587 contents = TAG[""]( 588 DIV(channel("N", 589 _class="small-12 columns db-box db-box-n", 590 ), 591 _class="row", 592 ), 593 DIV(channel("W", 594 _class="small-3 columns db-box db-box-w", 595 ), 596 channel("C", 597 _class="small-6 columns db-box db-box-c", 598 ), 599 channel("E", 600 _class="small-3 columns db-box db-box-e", 601 ), 602 _class="row", 603 ), 604 DIV(channel("S", 605 _class="small-12 columns db-box db-box-s", 606 ), 607 _class="row", 608 ), 609 ) 610 611 return contents
612
613 614 # ============================================================================= 615 -class S3DashboardColumnsLayout(S3DashboardLayout):
616 # @todo 617 pass
618
619 # ============================================================================= 620 -class S3DashboardGridLayout(S3DashboardLayout):
621 # @todo 622 pass
623
624 # ============================================================================= 625 -class S3Dashboard(object):
626 """ 627 Class to build and manage dashboards 628 629 def my_controller(): 630 631 config = { 632 # The default layout for the dashboard 633 "layout": "...", 634 635 # Available widgets 636 "widgets": {...}, 637 638 # Default Widget Configurations 639 "default": [...], 640 641 # Allow the user to configure this dashboard 642 "configurable": True, 643 } 644 645 dashboard = S3Dashboard(config) 646 return dashboard() 647 """ 648 649 # ------------------------------------------------------------------------- 650 # Standard layouts 651 # - custom layouts/overrides can be specifed in constructor 652 # 653 layouts = {"boxes": ("Boxes", S3DashboardBoxesLayout), 654 "columns": ("Columns", S3DashboardColumnsLayout), 655 "grid": ("Grid", S3DashboardGridLayout), 656 } 657 658 # -------------------------------------------------------------------------
659 - def __init__(self, config, layouts=None):
660 """ 661 Initializes the dashboard 662 663 @param config: the default configuration for this dashboard 664 @param layouts: custom layouts to override/extend the available 665 layouts, as dict {name: (label, class)} 666 """ 667 668 if not isinstance(config, S3DashboardConfig): 669 config = S3DashboardConfig(config) 670 self._config = config 671 672 self.context = S3DashboardContext(dashboard=self) 673 674 available_layouts = dict(self.layouts) 675 if layouts: 676 available_layouts.update(layouts) 677 self.available_layouts = available_layouts 678 679 self._agents = None
680 681 # ------------------------------------------------------------------------- 682 @property
683 - def config(self):
684 """ 685 Lazy property to load the current configuration from the database 686 687 @return: the S3DashboardConfig 688 """ 689 690 config = self._config 691 if not config.loaded: 692 config.load(self.context) 693 694 return config
695 696 # ------------------------------------------------------------------------- 697 @property
698 - def agents(self):
699 """ 700 Lazy property to instantiate the dashboard agents 701 702 @return: a dict {agent_id: agent} 703 """ 704 705 agents = self._agents 706 if agents is None: 707 708 # The dashboard configuration 709 config = self.config 710 711 # The current context 712 context = self.context 713 714 # Config details 715 config_id = config.config_id 716 available_widgets = config.available_widgets 717 next_id = config.next_id 718 719 agents = self._agents = {} 720 for index, widget_config in enumerate(config.active_widgets): 721 722 # Get the widget type 723 name = widget_config.get("widget") 724 widget = available_widgets.get(name) 725 if not widget: 726 continue 727 728 # Get the widget ID 729 if config_id is None: 730 widget_id = next_id 731 else: 732 widget_id = widget_config.get("widget_id") 733 if widget_id is None: 734 widget_id = next_id 735 next_id = max(next_id, widget_id + 1) 736 widget_config["widget_id"] = widget_id 737 738 # Construct the agent ID 739 agent_id = "db-widget-%s" % widget_id 740 if not agent_id: 741 continue 742 743 # Instantiate the agent 744 agent = widget.create_agent(agent_id, 745 config = widget_config, 746 version = config.version, 747 context = context, 748 ) 749 750 # Register the agent 751 agents[agent_id] = agent 752 753 config.next_id = next_id 754 755 return agents
756 757 # -------------------------------------------------------------------------
758 - def __call__(self, **attr):
759 """ 760 Dispatch requests - this method is called by the controller. 761 762 @param attr: keyword arguments from the controller 763 764 @keyword _id: the node ID for the dashboard (default: "dashboard") 765 766 @return: the output for the view 767 """ 768 769 # @todo: handle the side menu (if any) 770 771 context = self.context 772 773 agent_id = context.agent 774 http = context.http 775 command = context.command 776 777 status, msg = None, None 778 779 # Verify that the requested version matches the current config 780 if agent_id or command or http != "GET": 781 request_version = context.get_vars.get("version") 782 if not request_version: 783 context.error(400, 784 current.T("Invalid Request URL (version key missing)"), 785 ) 786 if request_version != self.config.version: 787 context.error(409, 788 current.T("Page configuration has changed, please reload the page"), 789 _next = URL(args=[], vars={}), 790 ) 791 792 if not agent_id: 793 # Global request 794 if http == "GET": 795 if command: 796 # Global command 797 output, error = self.do(command, context) 798 if error: 799 status, msg = error 800 else: 801 # Build dashboard 802 if context.representation == "html": 803 output = self.build(**attr) 804 else: 805 status, msg = 415, current.ERROR.BAD_FORMAT 806 elif http == "POST": 807 if command: 808 # Global command 809 output, error = self.do(command, context) 810 if error: 811 status, msg = error 812 else: 813 # POST requires command 814 status, msg = 405, current.ERROR.BAD_METHOD 815 else: 816 status, msg = 405, current.ERROR.BAD_METHOD 817 818 elif context.bulk: 819 # Multi-agent request (bulk status check) 820 # @todo: implement 821 status, msg = 501, current.ERROR.NOT_IMPLEMENTED 822 823 else: 824 # Single-agent request 825 agent = self.agents.get(agent_id) 826 if agent: 827 # Call the agent 828 output, error = agent(self, context) 829 if error: 830 status, msg = error 831 else: 832 status, msg = 404, current.ERROR.BAD_RESOURCE 833 834 if status: 835 context.error(status, msg) 836 else: 837 return output
838 839 # -------------------------------------------------------------------------
840 - def build(self, **attr):
841 """ 842 Build the dashboard and all its contents 843 844 @param attr: keyword arguments from the controller 845 846 @return: the output dict for the view 847 """ 848 849 config = self.config 850 context = self.context 851 852 dashboard_id = attr.get("_id", "dashboard") 853 854 # Switch for config mode 855 hide = " hide" if not config.configurable else "" 856 switch = SPAN(ICON("settings", 857 _class = "db-config-on%s" % hide, 858 ), 859 ICON("done", 860 _class = "db-config-off hide"), 861 _class = "db-config", 862 data = {"mode": "off"}, 863 ) 864 865 output = {"title": config.title, 866 "contents": "", 867 "dashboard_id": dashboard_id, 868 "switch": switch, 869 } 870 871 # Script Options 872 ajax_url = URL(args=[], vars={}) 873 874 script_options = {"ajaxURL": ajax_url, 875 "version": config.version, 876 } 877 878 # Inject JavaScript 879 # - before building the widgets, so that widgets can subclass 880 self.inject_script(dashboard_id, options=script_options) 881 882 # Instantiate the layout for the active config 883 layout = self.get_active_layout(config) 884 885 # Build the widgets 886 for agent in self.agents.values(): 887 agent.add_widget(layout, context) 888 889 # Set the view 890 current.response.view = "dashboard.html" 891 892 # Build the layout 893 contents = layout.build(context) 894 895 if isinstance(contents, dict): 896 output.update(contents) 897 else: 898 output["contents"] = contents 899 900 return output
901 902 # -------------------------------------------------------------------------
903 - def do(self, command, context):
904 """ 905 Execute a dashboard global command 906 907 @param command: the command 908 @param context: the current S3DashboardContext 909 910 @todo: implement global commands 911 """ 912 913 output = None 914 error = (501, current.ERROR.NOT_IMPLEMENTED) 915 916 return output, error
917 918 # -------------------------------------------------------------------------
919 - def get_active_layout(self, config):
920 """ 921 Get the active layout 922 923 @param config: the active dashboard configuration 924 @return: an instance of the active layout 925 """ 926 927 layout = self.available_layouts.get(config.layout) 928 929 if layout is None: 930 layout = S3DashboardBoxesLayout 931 elif type(layout) is tuple: 932 # Can be a tuple to specify a label for the layout selector 933 layout = layout[-1] 934 935 return layout(config)
936 937 # ------------------------------------------------------------------------- 938 @staticmethod
939 - def inject_script(dashboard_id, options=None):
940 """ 941 Inject the JS to instantiate the client-side widget controller 942 943 @param dashboard_id: the dashboard DOM node ID 944 @param options: JSON-serializable dict with script options 945 """ 946 947 s3 = current.response.s3 948 949 scripts = s3.scripts 950 appname = current.request.application 951 952 # Inject UI widget script 953 if s3.debug: 954 script = "/%s/static/scripts/S3/s3.ui.dashboard.js" % appname 955 if script not in scripts: 956 scripts.append(script) 957 else: 958 script = "/%s/static/scripts/S3/s3.ui.dashboard.min.js" % appname 959 if script not in scripts: 960 scripts.append(script) 961 962 # Inject widget instantiation 963 if not options: 964 options = {} 965 script = """$("#%(dashboard_id)s").dashboardController(%(options)s)""" % \ 966 {"dashboard_id": dashboard_id, 967 "options": json.dumps(options), 968 } 969 s3.jquery_ready.append(script)
970
971 972 # ============================================================================= 973 -class delegated(object):
974 """ 975 Decorator for widget methods that shall be exposed in the web API. 976 977 Delegated methods will be available as URL commands, so that 978 client-side scripts can send Ajax requests directly to their 979 agent: 980 981 /my/dashboard/[command]?agent=[agent-id] 982 983 Delegated methods will be executed in the context of the agent 984 rather than of the widget (therefore "delegated"), so that they 985 have access to the agent configuration. 986 987 Pattern: 988 989 @delegated 990 def example(agent, context): 991 992 # Accessing the agent config: 993 config = agent.config 994 995 # Accessing the widget context (=self): 996 widget = agent.widget 997 998 # Accessing other agents of the same widget: 999 agents = widget.agents 1000 1001 # do something with the context, return output 1002 return {"output": "something"} 1003 """ 1004
1005 - def __init__(self, function):
1006 self.function = function
1007
1008 - def __call__(self, function):
1009 self.function = function 1010 return self
1011
1012 - def execute(self, agent, context):
1013 function = self.function 1014 if callable(function): 1015 output = function(agent, context) 1016 else: 1017 output = function 1018 return output
1019
1020 # ============================================================================= 1021 -class S3DashboardAgent(object):
1022 """ 1023 Object serving a dashboard widget 1024 1025 - renders the widget according to the active configuration 1026 - dispatches Ajax requests to widget methods 1027 - manages the widget configuration 1028 """ 1029
1030 - def __init__(self, agent_id, widget=None, config=None, version=None):
1031 """ 1032 Initialize the agent 1033 1034 @param agent_id: the agent ID (string), a unique XML 1035 identifier for the widget configuration 1036 @param widget: the widget (S3DashboardWidget instance) 1037 @param config: the widget configuration (dict) 1038 @param version: the config version 1039 """ 1040 1041 self.agent_id = agent_id 1042 self.widget = widget 1043 1044 self.config = config 1045 self.version = version
1046 1047 # -------------------------------------------------------------------------
1048 - def __call__(self, dashboard, context):
1049 """ 1050 Dispatch Ajax requests 1051 1052 @param dashboard: the calling S3Dashboard instance 1053 @param context: the current S3DashboardContext 1054 1055 @return: tuple (output, error), where: 1056 - "output" is the output of the command execution 1057 - "error" is a tuple (http_status, message), or None 1058 """ 1059 1060 command = context.command 1061 representation = context.representation 1062 1063 output = None 1064 error = None 1065 1066 if command: 1067 if command == "config": 1068 if representation == "popup": 1069 output = self.configure(dashboard, context) 1070 else: 1071 error = (415, current.ERROR.BAD_FORMAT) 1072 elif command == "authorize": 1073 # Placeholder for agent command 1074 # @todo: implement authorize-action 1075 error = (501, current.ERROR.NOT_IMPLEMENTED) 1076 else: 1077 # Delegated command 1078 try: 1079 output = self.do(command, context) 1080 except NotImplementedError: 1081 error = (501, current.ERROR.NOT_IMPLEMENTED) 1082 else: 1083 if context.http == "GET": 1084 if representation in ("html", "iframe"): 1085 # Return the widget XML 1086 output = self.widget.widget(self.agent_id, 1087 self.config, 1088 context = context, 1089 ) 1090 else: 1091 error = (415, current.ERROR.BAD_FORMAT) 1092 else: 1093 error = (405, current.ERROR.BAD_METHOD) 1094 1095 return output, error
1096 1097 # -------------------------------------------------------------------------
1098 - def do(self, command, context):
1099 """ 1100 Execute a delegated widget method 1101 1102 @param command: the name of the delegated widget method 1103 @param context: the S3DashboardContext 1104 """ 1105 1106 widget = self.widget 1107 1108 msg = "%s does not expose a '%s' method" 1109 exception = lambda: NotImplementedError(msg % (widget.__class__.__name__, 1110 command, 1111 )) 1112 try: 1113 method = getattr(widget, command) 1114 except AttributeError: 1115 raise exception() 1116 if type(method) is not delegated: 1117 raise exception() 1118 1119 return method.execute(self, context)
1120 1121 # -------------------------------------------------------------------------
1122 - def add_widget(self, layout, context):
1123 """ 1124 Build the widget XML for the context, and add it to the layout 1125 """ 1126 1127 config = self.config 1128 prototype = self.widget 1129 1130 # Produce the contents XML 1131 contents = prototype.widget(self.agent_id, 1132 config, 1133 version = self.version, 1134 context = context, 1135 ) 1136 1137 # Get the config bar 1138 configbar = prototype.configbar() 1139 1140 # Construct the widget 1141 widget = DIV(configbar, 1142 contents, 1143 _class = "db-widget", 1144 _id = self.agent_id, 1145 ) 1146 1147 # Add script file 1148 prototype._load_script() 1149 1150 # Determine channel and position from config 1151 channel = config.get("channel", DEFAULT) 1152 position = config.get("position", None) 1153 1154 # Add the widget to the layout 1155 layout.add_widget(widget, 1156 channel=channel, 1157 position=position, 1158 )
1159 1160 # -------------------------------------------------------------------------
1161 - def configure(self, dashboard, context):
1162 """ 1163 Controller for the widget configuration dialog 1164 1165 @param dashboard: the calling S3Dashboard instance 1166 @param context: the S3DashboardContext 1167 1168 @return: output dict for the view 1169 """ 1170 1171 response = current.response 1172 s3 = response.s3 1173 1174 # Get the form fields from the widget class 1175 prototype = self.widget 1176 formfields = prototype.configure(self) 1177 1178 # The current configuration as formdata 1179 formdata = dict(self.config) 1180 formdata["id"] = 0 1181 1182 # Form buttons 1183 T = current.T 1184 submit_btn = INPUT(_class = "tiny primary button submit-btn", 1185 _name = "submit", 1186 _type = "submit", 1187 _value = T("Submit"), 1188 ) 1189 buttons = [submit_btn] 1190 1191 # Construct the form 1192 settings = s3.crud 1193 formstyle = settings.formstyle 1194 form = SQLFORM.factory(*formfields, 1195 record = formdata, 1196 showid = False, 1197 formstyle = formstyle, 1198 table_name = "config", 1199 upload = s3.download_url, 1200 separator = "", 1201 submit_button = settings.submit_button, 1202 buttons = buttons) 1203 1204 # Process the form 1205 formname = "config/%s" % self.agent_id 1206 if form.accepts(context.post_vars, 1207 current.session, 1208 onvalidation = prototype.validate_config, 1209 formname = formname, 1210 keepvalues = False, 1211 hideerror = False, 1212 ): 1213 1214 # Get an updated config dict from the widget 1215 widget_config = self.config 1216 widget_id = widget_config.get("widget_id") 1217 1218 new_config = prototype.accept_config(widget_config, form) 1219 1220 # Pass new config to client via s3.popup_data 1221 popup_data = {"c": new_config} 1222 1223 # Save the new config and add the new version key to the popup_data 1224 if dashboard: 1225 version = dashboard.config.save(context, {widget_id: new_config}) 1226 if version: 1227 popup_data["v"] = version 1228 1229 # Using JSON serializer rather than raw json.dumps to catch T()'s 1230 from gluon.serializers import json as jsons 1231 s3.popup_data = jsons(popup_data) 1232 1233 # Send a confirmation so the popup gets closed 1234 # (layout.html diverts to layout_popup.html with 1235 # "popup" request format + response.confirmation) 1236 response.confirmation = T("Configuration updated") 1237 1238 # Set view (@todo: implement specific view?) 1239 response.view = "popup.html" 1240 1241 return {# Context needed for layout.html to determine 1242 # the representation format: 1243 "r": context, 1244 "form": form, 1245 }
1246
1247 # ============================================================================= 1248 -class S3DashboardWidget(object):
1249 """ 1250 Base class for dashboard widgets 1251 """ 1252 1253 title = "Dashboard Widget" 1254 1255 # ------------------------------------------------------------------------- 1256 # Methods to be implemented by subclasses 1257 # -------------------------------------------------------------------------
1258 - def widget(self, agent_id, config, version=None, context=None):
1259 """ 1260 Construct the XML for this widget 1261 1262 @param agent_id: the agent ID (same as the DOM node ID of the 1263 outer wrapper DIV, to attach scripts) 1264 @param config: the active widget configuration 1265 @param version: the config version key 1266 @param context: the S3DashboardContext 1267 1268 @return: an XmlComponent with the widget contents, 1269 the outer DIV will be added by the agent 1270 """ 1271 1272 # Base class just renders some static XML 1273 contents = config.get("xml", "") 1274 1275 # Inject the JavaScript components 1276 self.inject_script(agent_id, version=version) 1277 1278 return XML(contents)
1279 1280 # -------------------------------------------------------------------------
1281 - def get_script_path(self, debug=False):
1282 """ 1283 Get the path to the script file for this widget, can be 1284 implemented in subclasses to override the default. 1285 1286 @param debug: whether running in debug mode or not 1287 1288 @return: path relative to static/scripts, 1289 None if no separate script file required 1290 """ 1291 1292 #if debug: 1293 # return "S3/mywidget.js" 1294 #else: 1295 # return "S3/mywidget.min.js" 1296 1297 # No separate script file required for base class 1298 return None
1299 1300 # -------------------------------------------------------------------------
1301 - def configure(self, agent):
1302 """ 1303 Get widget-specific configuration form fields 1304 1305 @param agent: the agent 1306 1307 @return: a list of Fields for the form construction 1308 """ 1309 1310 # Generic widget allows configuration of XML 1311 formfields = [Field("xml", "text", 1312 label = "XML", 1313 ), 1314 ] 1315 1316 return formfields
1317 1318 # -------------------------------------------------------------------------
1319 - def accept_config(self, config, form):
1320 """ 1321 Extract the new config settings from the form and 1322 update the config dict 1323 1324 @param config: the config dict 1325 @param form: the configuration form 1326 1327 @return: the updated config dict (can be a replacement) 1328 1329 NB config must remain JSON-serializable 1330 """ 1331 1332 formvars = form.vars 1333 1334 xml = formvars.get("xml") 1335 if xml is not None: 1336 config["xml"] = xml 1337 1338 return config
1339 1340 # -------------------------------------------------------------------------
1341 - def validate_config(self, form):
1342 """ 1343 Validation function for configuration form 1344 """ 1345 1346 # Generic widget has nothing to validate 1347 pass
1348 1349 # ------------------------------------------------------------------------- 1350 # Helpers 1351 # ------------------------------------------------------------------------- 1352 @classmethod
1353 - def inject_script(cls, 1354 agent_id, 1355 version=None, 1356 widget_class="dashboardWidget", 1357 options=None):
1358 """ 1359 Helper method to inject the init script for a particular agent, 1360 usually called by widget() method. 1361 1362 @param agent_id: the agent ID 1363 @param version: the config version key 1364 @param widget_class: the widget class to instantiate 1365 @param options: JSON-serializable dict of options to pass 1366 to the widget instance 1367 """ 1368 1369 s3 = current.response.s3 1370 1371 if not agent_id or not widget_class: 1372 return 1373 if not options: 1374 options = {} 1375 1376 # Add the widget title (for the configuration popup) 1377 title = cls.title 1378 if title: 1379 options["title"] = s3_str(current.T(title)) 1380 1381 # Add the dashboard URL 1382 dashboard_url = URL(args=[], vars={}) 1383 options["dashboardURL"] = dashboard_url 1384 1385 # Add the config version key 1386 options["version"] = version 1387 1388 script = """$("#%(agent_id)s").%(widget_class)s(%(options)s)""" % \ 1389 {"agent_id": agent_id, 1390 "widget_class": widget_class, 1391 "options": json.dumps(options), 1392 } 1393 s3.jquery_ready.append(script)
1394 1395 # ------------------------------------------------------------------------- 1396 @staticmethod
1397 - def configbar():
1398 """ 1399 Build the widget configuration task bar 1400 1401 @return: the XML for the task bar 1402 """ 1403 1404 return DIV(SPAN(ICON("move", _class="db-task-move"), 1405 _class="db-configbar-left", 1406 ), 1407 SPAN(ICON("delete", _class="db-task-delete"), 1408 ICON("settings", _class="db-task-config"), 1409 _class="db-configbar-right", 1410 ), 1411 _class = "db-configbar", 1412 )
1413 1414 # ------------------------------------------------------------------------- 1415 # Base class methods 1416 # -------------------------------------------------------------------------
1417 - def __init__(self, 1418 label=None, 1419 defaults=None, 1420 on_create_agent=None, 1421 **options 1422 ):
1423 """ 1424 Initialize the widget, called when configuring an available 1425 widget for the dashboard. 1426 1427 @param label: a label for this widget type, used in the widget 1428 selector in the configuration GUI; if left empty, 1429 then the widget type will not appear in the selector 1430 @param defaults: the default configuration for this widget 1431 @param on_create_agent: callback, invoked when an agent is created 1432 for this widget: 1433 - on_create_agent(agent, context) 1434 @param **options: type-specific options 1435 """ 1436 1437 self.label = label 1438 1439 # The default configuration for this widget 1440 if defaults is None: 1441 defaults = {} 1442 self.defaults = defaults 1443 1444 # Widget-type specific options 1445 self.options = options 1446 1447 # Hooks 1448 self.on_create_agent = on_create_agent 1449 1450 self.agents = {} 1451 self.script_loaded = False
1452 1453 # -------------------------------------------------------------------------
1454 - def create_agent(self, agent_id, config=None, version=None, context=None):
1455 """ 1456 Create an agent for this widget 1457 1458 @param agent_id: the agent ID 1459 @param config: the agent configuration dict 1460 @param version: the config version key 1461 @param context: the current S3DashboardContext 1462 """ 1463 1464 # Add widget defaults to configuration 1465 agent_config = dict(self.defaults) 1466 if config: 1467 agent_config.update(config) 1468 1469 # Create or update agent for agent_id 1470 agent = self.agents.get(agent_id) 1471 if agent: 1472 # Update the agent configuration 1473 agent.config = agent_config 1474 agent.version = version 1475 else: 1476 # Create a new agent 1477 agent = S3DashboardAgent(agent_id, 1478 widget=self, 1479 config=agent_config, 1480 version=version, 1481 ) 1482 self.agents[agent_id] = agent 1483 1484 # Callback 1485 on_create_agent = self.on_create_agent 1486 if on_create_agent: 1487 on_create_agent(agent, context) 1488 1489 return agent
1490 1491 # -------------------------------------------------------------------------
1492 - def _load_script(self):
1493 """ 1494 Add the script file to s3.scripts, called when an agent 1495 builds the widget 1496 """ 1497 1498 if self.script_loaded: 1499 return 1500 1501 s3 = current.response.s3 1502 scripts = s3.scripts 1503 1504 path = self.get_script_path(debug=s3.debug) 1505 if path: 1506 appname = current.request.application 1507 1508 # Add script to s3.scripts 1509 script = "/%s/static/scripts/%s" % (appname, path) 1510 if script not in scripts: 1511 scripts.append(script) 1512 1513 self.script_loaded = True
1514 1515 # END ========================================================================= 1516