1
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
64
65
66 self.dashboard = dashboard
67 self.shared = {}
68
69
70
71 self.filters = {}
72
73
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
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
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
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
191
192
193
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
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
247
248 self.config_id = None
249
250
251 self.title = title
252
253
254 if layout is None:
255 layout = self.DEFAULT_LAYOUT
256 self.layout = layout
257
258
259 if not widgets:
260 widgets = {}
261 self.available_widgets = widgets
262
263
264 if not default:
265 default = []
266 self.active_widgets = default
267
268 self.version = None
269 self.next_id = 0
270
271
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
302 self.version = row.version
303 if row.next_id:
304 self.next_id = row.next_id
305
306
307 self.layout = row.layout
308 if row.title:
309 self.title = row.title
310
311
312 widgets = row.widgets
313 if type(widgets) is list:
314 self.active_widgets = widgets
315
316
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
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
341 widgets = self.active_widgets
342
343
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
355 version = uuid.uuid4().get_hex()
356
357 config_id = self.config_id
358 if not config_id:
359
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
371 else:
372
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
381
382 if version:
383 self.version = version
384
385 return version
386
389 """
390 A dashboard channel
391 (=a section of the dashboard where widgets can be added)
392 """
393
395 """
396 Constructor
397 """
398
399 self.widgets = {}
400
401
416
417
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
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
453 """
454 Base class for dashboard layouts, can be subclassed
455 to implement custom layouts
456 """
457
458
459
460 CHANNELS = None
461
462
463 DEFAULT_CHANNEL = None
464
465
466
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
510
511
512
513
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(" ")
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
536
538 """
539 Constructor
540
541 @param config: the active S3DashboardConfig
542 """
543
544 self.config = config
545
546
547 CHANNELS = self.CHANNELS
548 if CHANNELS:
549 self.channels = dict((name, S3DashboardChannel()) for name in CHANNELS)
550 else:
551 self.channels = {}
552
555 """
556 Simple 5-boxes layout for dashboards
557
558 +--------------------+
559 | N |
560 +----+----------+----+
561 | W | C | E |
562 +----+----------+----+
563 | S |
564 +--------------------+
565 """
566
567
568 CHANNELS = ("N", "W", "C", "E", "S")
569
570
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
618
623
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
651
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
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
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
709 config = self.config
710
711
712 context = self.context
713
714
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
723 name = widget_config.get("widget")
724 widget = available_widgets.get(name)
725 if not widget:
726 continue
727
728
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
739 agent_id = "db-widget-%s" % widget_id
740 if not agent_id:
741 continue
742
743
744 agent = widget.create_agent(agent_id,
745 config = widget_config,
746 version = config.version,
747 context = context,
748 )
749
750
751 agents[agent_id] = agent
752
753 config.next_id = next_id
754
755 return agents
756
757
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
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
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
794 if http == "GET":
795 if command:
796
797 output, error = self.do(command, context)
798 if error:
799 status, msg = error
800 else:
801
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
809 output, error = self.do(command, context)
810 if error:
811 status, msg = error
812 else:
813
814 status, msg = 405, current.ERROR.BAD_METHOD
815 else:
816 status, msg = 405, current.ERROR.BAD_METHOD
817
818 elif context.bulk:
819
820
821 status, msg = 501, current.ERROR.NOT_IMPLEMENTED
822
823 else:
824
825 agent = self.agents.get(agent_id)
826 if agent:
827
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
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
872 ajax_url = URL(args=[], vars={})
873
874 script_options = {"ajaxURL": ajax_url,
875 "version": config.version,
876 }
877
878
879
880 self.inject_script(dashboard_id, options=script_options)
881
882
883 layout = self.get_active_layout(config)
884
885
886 for agent in self.agents.values():
887 agent.add_widget(layout, context)
888
889
890 current.response.view = "dashboard.html"
891
892
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
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
933 layout = layout[-1]
934
935 return layout(config)
936
937
938 @staticmethod
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
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
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
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
1006 self.function = function
1007
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
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
1074
1075 error = (501, current.ERROR.NOT_IMPLEMENTED)
1076 else:
1077
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
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
1159
1160
1246
1514
1515
1516