1
2
3 """ S3 Hierarchy Toolkit
4
5 @copyright: 2013-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
32 __all__ = ("S3Hierarchy", "S3HierarchyCRUD")
33
34 import json
35
36 from gluon import *
37 from gluon.storage import Storage
38 from gluon.tools import callback
39 from s3utils import s3_str
40 from s3rest import S3Method
41 from s3widgets import SEPARATORS
42
43 DEFAULT = lambda: None
47 """ Method handler for hierarchical CRUD """
48
49
51 """
52 Entry point for REST interface
53
54 @param r: the S3Request
55 @param attr: controller attributes
56 """
57
58 if r.http == "GET":
59 if r.representation == "html":
60 output = self.tree(r, **attr)
61 elif r.representation == "json" and "node" in r.get_vars:
62 output = self.node_json(r, **attr)
63 elif r.representation == "xls":
64 output = self.export_xls(r, **attr)
65 else:
66 r.error(415, current.ERROR.BAD_FORMAT)
67 else:
68 r.error(405, current.ERROR.BAD_METHOD)
69
70 return output
71
72
73 - def tree(self, r, **attr):
74 """
75 Page load
76
77 @param r: the S3Request
78 @param attr: controller attributes
79 """
80
81 output = {}
82
83 resource = self.resource
84 tablename = resource.tablename
85
86
87 widget_id = "%s-hierarchy" % tablename
88
89
90 try:
91 tree = self.render_tree(widget_id, record=r.record)
92 except SyntaxError:
93 r.error(405, "No hierarchy configured for %s" % tablename)
94
95
96 if r.record:
97 title = self.crud_string(tablename, "title_display")
98 else:
99 title = self.crud_string(tablename, "title_list")
100 output["title"] = title
101
102
103 form = FORM(DIV(tree,
104 _class="s3-hierarchy-tree",
105 ),
106 _id = widget_id,
107 )
108 output["form"] = form
109
110
111 T = current.T
112 crud_string = lambda name: self.crud_string(tablename, name)
113
114 widget_opts = {
115 "widgetID": widget_id,
116
117 "openLabel": str(T("Open")),
118 "openURL": r.url(method="read", id="[id]"),
119 "ajaxURL": r.url(id=None, representation="json"),
120
121 "editLabel": str(T("Edit")),
122 "editTitle": str(crud_string("title_update")),
123
124 "addLabel": str(T("Add")),
125 "addTitle": str(crud_string("label_create")),
126
127 "deleteLabel": str(T("Delete")),
128 "deleteRoot": False if r.record else True
129 }
130
131
132 resource_config = resource.get_config
133 has_permission = current.auth.s3_has_permission
134 if resource_config("editable", True) and \
135 has_permission("update", tablename):
136 widget_opts["editURL"] = r.url(method = "update",
137 id = "[id]",
138 representation = "popup",
139 )
140
141 if resource_config("deletable", True) and \
142 has_permission("delete", tablename):
143 widget_opts["deleteURL"] = r.url(method = "delete",
144 id = "[id]",
145 representation = "json",
146 )
147
148 if resource_config("insertable", True) and \
149 has_permission("create", tablename):
150 widget_opts["addURL"] = r.url(method = "create",
151 representation = "popup",
152 )
153
154
155 theme = current.deployment_settings.get_ui_hierarchy_theme()
156 icons = theme.get("icons", False)
157 if icons:
158
159 widget_opts["icons"] = icons
160 stripes = theme.get("stripes", True)
161 if not stripes:
162
163 widget_opts["stripes"] = stripes
164 self.include_scripts(widget_id, widget_opts)
165
166
167 current.response.view = self._view(r, "hierarchy.html")
168
169 return output
170
171
173 """
174 Return a single node as JSON (id, parent and label)
175
176 @param r: the S3Request
177 @param attr: controller attributes
178 """
179
180 resource = self.resource
181 tablename = resource.tablename
182
183 h = S3Hierarchy(tablename = tablename)
184 if not h.config:
185 r.error(405, "No hierarchy configured for %s" % tablename)
186
187 data = {}
188 node_id = r.get_vars["node"]
189 if node_id:
190 try:
191 node_id = long(node_id)
192 except ValueError:
193 pass
194 else:
195 data["node"] = node_id
196 label = h.label(node_id)
197 data["label"] = label if label else None
198 data["parent"] = h.parent(node_id)
199
200 children = h.children(node_id)
201 if children:
202 nodes = []
203 h._represent(node_ids=children)
204 for child_id in children:
205 label = h.label(child_id)
206
207 nodes.append({"node": child_id,
208 "label": label if label else None,
209 })
210 data["children"] = nodes
211
212 current.response.headers["Content-Type"] = "application/json"
213 return json.dumps(data, separators = SEPARATORS)
214
215
217 """
218 Render the tree
219
220 @param widget_id: the widget ID
221 @param record: the root record (if requested)
222 """
223
224 resource = self.resource
225 tablename = resource.tablename
226
227 h = S3Hierarchy(tablename = tablename)
228 if not h.config:
229 raise SyntaxError()
230
231 root = None
232 if record:
233 try:
234 root = record[h.pkey]
235 except AttributeError as e:
236
237 msg = "S3Hierarchy: key %s not found in record" % h.pkey
238 e.args = tuple([msg] + list(e.args[1:]))
239 raise
240
241
242 return h.html("%s-tree" % widget_id, root=root)
243
244
245 @staticmethod
247 """ Include JS & CSS needed for hierarchical CRUD """
248
249 s3 = current.response.s3
250 scripts = s3.scripts
251 theme = current.deployment_settings.get_ui_hierarchy_theme()
252
253
254 script_dir = "/%s/static/scripts" % current.request.application
255 if s3.debug:
256 script = "%s/jstree.js" % script_dir
257 if script not in scripts:
258 scripts.append(script)
259 script = "%s/S3/s3.ui.hierarchicalcrud.js" % script_dir
260 if script not in scripts:
261 scripts.append(script)
262 style = "%s/jstree.css" % theme.get("css", "plugins")
263 if style not in s3.stylesheets:
264 s3.stylesheets.append(style)
265 else:
266 script = "%s/S3/s3.jstree.min.js" % script_dir
267 if script not in scripts:
268 scripts.append(script)
269 style = "%s/jstree.min.css" % theme.get("css", "plugins")
270 if style not in s3.stylesheets:
271 s3.stylesheets.append(style)
272
273
274 script = '''$('#%(widget_id)s').hierarchicalcrud(%(widget_opts)s)''' % \
275 {"widget_id": widget_id,
276 "widget_opts": json.dumps(widget_opts, separators=SEPARATORS),
277 }
278 s3.jquery_ready.append(script)
279
280 return
281
282
284 """
285 Export nodes in the hierarchy in XLS format, including
286 ancestor references.
287
288 This is controlled by the hierarchy_export setting, which is
289 a dict like:
290 {
291 "field": "name", - the field name for the ancestor reference
292 "root": "Organisation" - the label for the root level
293 "branch": "Branch" - the label for the branch levels
294 "prefix": "Sub" - the prefix for the branch label
295 }
296
297 With this configuration, the ancestor columns would appear like:
298
299 Organisation, Branch, SubBranch, SubSubBranch, SubSubSubBranch,...
300
301 All parts of the setting can be omitted, the defaults are as follows:
302 - "field" defaults to "name"
303 - "root" is automatically generated from the resource name
304 - "branch" defaults to prefix+root
305 - "prefix" defaults to "Sub"
306
307 @status: experimental
308 """
309
310 resource = self.resource
311 tablename = resource.tablename
312
313
314 h = S3Hierarchy(tablename=tablename)
315 if not h.config:
316 r.error(405, "No hierarchy configured for %s" % tablename)
317
318
319 setting = resource.get_config("hierarchy_export", {})
320 field = setting.get("field")
321 if not field:
322 field = "name" if "name" in resource.fields else resource._id.name
323 prefix = setting.get("prefix", "Sub")
324 root = setting.get("root")
325 if not root:
326 root = "".join(s.capitalize() for s in resource.name.split("_"))
327 branch = setting.get("branch")
328 if not branch:
329 branch = "%s%s" % (prefix, root)
330
331 rfield = resource.resolve_selector(field)
332
333
334 list_fields = resource.list_fields("export_fields", id_column=False)
335 rfields = resource.resolve_selectors(list_fields,
336 extra_fields=False,
337 )[0]
338
339
340 selectors = [h.pkey.name, rfield.selector]
341
342
343
344 columns = []
345 types = []
346
347
348 headers = {}
349 for rf in rfields:
350 selectors.append(rf.selector)
351 if rf.colname == rfield.colname:
352 continue
353 columns.append(rf.colname)
354 headers[rf.colname] = rf.label
355 if rf.ftype == "virtual":
356 types.append("string")
357 else:
358 types.append(rf.ftype)
359
360
361 if self.record_id:
362 if r.component and h.pkey.name != resource._id.name:
363 query = resource.table._id == self.record_id
364 row = current.db(query).select(h.pkey, limitby=(0, 1)).first()
365 if not row:
366 r.error(404, current.ERROR.BAD_RECORD)
367 roots = set([row[h.pkey]])
368 else:
369 roots = set([self.record_id])
370 else:
371 roots = h.roots
372
373
374 all_nodes = h.findall(roots, inclusive=True)
375
376
377 from s3query import FS
378 query = FS(h.pkey.name).belongs(all_nodes)
379 clone = current.s3db.resource(resource, filter=query)
380 data = clone.select(selectors, represent=True, raw_data=True)
381
382
383 hkey = str(h.pkey)
384 data_dict = dict((row._row[hkey], row) for row in data.rows)
385
386
387 depth = max(h.depth(node_id) for node_id in roots)
388 htypes = []
389 hcolumns = []
390 colprefix = "HIERARCHY"
391 htype = "string" if rfield.ftype == "virtual" else rfield.ftype
392 for level in xrange(depth+1):
393 col = "%s.%s" % (colprefix, level)
394 if level == 0:
395 headers[col] = root
396 elif level == 1:
397 headers[col] = branch
398 else:
399 headers[col] = "%s%s" % ("".join([prefix] * (level -1)), branch)
400 hcolumns.append(col)
401 htypes.append(htype)
402
403
404
405 output = [headers, htypes + types]
406 for node_id in roots:
407 rows = h.export_node(node_id,
408 prefix = colprefix,
409 depth=depth,
410 hcol=rfield.colname,
411 columns = columns,
412 data = data_dict,
413 )
414 output.extend(rows)
415
416
417 from s3codec import S3Codec
418 codec = S3Codec.get_codec("xls")
419 result = codec.encode(output,
420 title = resource.name,
421 list_fields=hcolumns+columns)
422
423
424 return result
425
428 """ Class representing an object hierarchy """
429
430
431 - def __init__(self,
432 tablename=None,
433 hierarchy=None,
434 represent=None,
435 filter=None,
436 leafonly=True,
437 ):
438 """
439 Constructor
440
441 @param tablename: the tablename
442 @param hierarchy: the hierarchy setting for the table
443 (replaces the current setting)
444 @param represent: a representation method for the node IDs
445 @param filter: additional filter query for the table to
446 select the relevant subset
447 @param leafonly: filter strictly for leaf nodes
448 """
449
450 self.tablename = tablename
451 if hierarchy:
452 current.s3db.configure(tablename, hierarchy=hierarchy)
453 self.represent = represent
454
455 self.filter = filter
456 self.leafonly = leafonly
457
458 self.__theset = None
459 self.__flags = None
460
461 self.__nodes = None
462 self.__roots = None
463
464 self.__pkey = None
465 self.__fkey = None
466 self.__ckey = None
467
468 self.__link = DEFAULT
469 self.__lkey = DEFAULT
470 self.__left = DEFAULT
471
472
473 @property
475 """
476 The raw nodes dict like:
477
478 {<node_id>: {"p": <parent_id>,
479 "c": <category>,
480 "s": set(child nodes)
481 }}
482 """
483
484 if self.__theset is None:
485 self.__connect()
486 if self.__status("dirty"):
487 self.read()
488 return self.__theset
489
490
491 @property
493 """ Dict of status flags """
494
495 if self.__flags is None:
496 theset = self.theset
497 return self.__flags
498
499
500 @property
502 """ Hierarchy configuration of the target table """
503
504 tablename = self.tablename
505 if tablename:
506 s3db = current.s3db
507 if tablename in current.db or \
508 s3db.table(tablename, db_only=True):
509 return s3db.get_config(tablename, "hierarchy")
510 return None
511
512
513 @property
515 """ The nodes in the subset """
516
517 theset = self.theset
518 if self.__nodes is None:
519 self.__subset()
520 return self.__nodes
521
522
523 @property
525 """ The IDs of the root nodes in the subset """
526
527 nodes = self.nodes
528 return self.__roots
529
530
531 @property
533 """ The parent key """
534
535 if self.__pkey is None:
536 self.__keys()
537 return self.__pkey
538
539
540 @property
542 """ The foreign key referencing the parent key """
543
544 if self.__fkey is None:
545 self.__keys()
546 return self.__fkey
547
548
549 @property
551 """
552 The name of the link table containing the foreign key, or
553 None if the foreign key is in the hierarchical table itself
554 """
555
556 if self.__link is DEFAULT:
557 self.__keys()
558 return self.__link
559
560
561 @property
563 """ The key in the link table referencing the child """
564
565 if self.__lkey is DEFAULT:
566 self.__keys()
567 return self.__lkey
568
569
570 @property
572 """ The left join with the link table containing the foreign key """
573
574 if self.__left is DEFAULT:
575 self.__keys()
576 return self.__left
577
578
579 @property
581 """ The category field """
582
583 if self.__ckey is None:
584 self.__keys()
585 return self.__ckey
586
587
589 """ Connect this instance to the hierarchy """
590
591 tablename = self.tablename
592 if tablename :
593 hierarchies = current.model["hierarchies"]
594 if tablename in hierarchies:
595 hierarchy = hierarchies[tablename]
596 self.__theset = hierarchy["nodes"]
597 self.__flags = hierarchy["flags"]
598 else:
599 self.__theset = dict()
600 self.__flags = dict()
601 self.load()
602 hierarchy = {"nodes": self.__theset,
603 "flags": self.__flags}
604 hierarchies[tablename] = hierarchy
605 else:
606 self.__theset = dict()
607 self.__flags = dict()
608 return
609
610
611 - def __status(self, flag=None, default=None, **attr):
612 """
613 Check or update status flags
614
615 @param flag: the name of the status flag to return
616 @param default: the default value if the flag is not set
617 @param attr: key-value pairs for flags to set
618
619 @return: the value of the requested flag, or all flags
620 as dict if no flag was specified
621 """
622
623 flags = self.flags
624 for k, v in attr.items():
625 if v is not None:
626 flags[k] = v
627 else:
628 flags.pop(k, None)
629 if flag is not None:
630 return flags.get(flag, default)
631 return flags
632
633
635 """ Try loading the hierarchy from s3_hierarchy """
636
637 if not self.config:
638 return
639 tablename = self.tablename
640
641 if not self.__status("dbstatus", True):
642
643 self.__status(dirty=True)
644 return
645
646 htable = current.s3db.s3_hierarchy
647 query = (htable.tablename == tablename)
648 row = current.db(query).select(htable.dirty,
649 htable.hierarchy,
650 limitby=(0, 1)).first()
651 if row and not row.dirty:
652 data = row.hierarchy
653 theset = self.__theset
654 theset.clear()
655 for node_id, item in data["nodes"].items():
656 theset[long(node_id)] = {"p": item["p"],
657 "c": item["c"],
658 "s": set(item["s"]) \
659 if item["s"] else set()}
660 self.__status(dirty=False,
661 dbupdate=None,
662 dbstatus=True)
663 return
664 else:
665 self.__status(dirty=True,
666 dbupdate=None,
667 dbstatus=False if row else None)
668 return
669
670
672 """ Save this hierarchy in s3_hierarchy """
673
674 if not self.config:
675 return
676 tablename = self.tablename
677
678 theset = self.theset
679 if not self.__status("dbupdate"):
680 return
681
682
683 nodes_dict = dict()
684 for node_id, node in theset.items():
685 nodes_dict[node_id] = {"p": node["p"],
686 "c": node["c"],
687 "s": list(node["s"]) \
688 if node["s"] else []}
689
690
691 data = {"tablename": tablename,
692 "dirty": False,
693 "hierarchy": {"nodes": nodes_dict}
694 }
695
696
697 htable = current.s3db.s3_hierarchy
698 query = (htable.tablename == tablename)
699 row = current.db(query).select(htable.id,
700 limitby=(0, 1)).first()
701
702 if row:
703
704 row.update_record(**data)
705 else:
706
707 htable.insert(**data)
708
709
710 self.__status(dirty=False, dbupdate=None, dbstatus=True)
711 return
712
713
714 @classmethod
715 - def dirty(cls, tablename):
716 """
717 Mark this hierarchy as dirty. To be called when the target
718 table gets updated (can be called repeatedly).
719
720 @param tablename: the tablename
721 """
722
723 s3db = current.s3db
724
725 if not tablename:
726 return
727 config = s3db.get_config(tablename, "hierarchy")
728 if not config:
729 return
730
731 hierarchies = current.model["hierarchies"]
732 if tablename in hierarchies:
733 hierarchy = hierarchies[tablename]
734 flags = hierarchy["flags"]
735 else:
736 flags = {}
737 hierarchies[tablename] = {"nodes": dict(),
738 "flags": flags}
739 flags["dirty"] = True
740
741 dbstatus = flags.get("dbstatus", True)
742 if dbstatus:
743 htable = current.s3db.s3_hierarchy
744 query = (htable.tablename == tablename)
745 row = current.db(query).select(htable.id,
746 htable.dirty,
747 limitby=(0, 1)).first()
748 if not row:
749 htable.insert(tablename=tablename, dirty=True)
750 elif not row.dirty:
751 row.update_record(dirty=True)
752 flags["dbstatus"] = False
753 return
754
755
801
802
804 """ Introspect the key fields in the hierarchical table """
805
806 tablename = self.tablename
807 if not tablename:
808 return
809
810 s3db = current.s3db
811 table = s3db[tablename]
812
813 config = s3db.get_config(tablename, "hierarchy")
814 if not config:
815 return
816
817 if isinstance(config, tuple):
818 parent, self.__ckey = config[:2]
819 else:
820 parent, self.__ckey = config, None
821
822 pkey = None
823 fkey = None
824 if parent is None:
825
826
827 pkey = table._id
828
829 for field in table:
830 ftype = str(field.type)
831 if ftype[:9] == "reference":
832 key = ftype[10:].split(".")
833 if key[0] == tablename and \
834 (len(key) == 1 or key[1] == pkey.name):
835 parent = field.name
836 fkey = field
837 break
838 else:
839 resource = s3db.resource(tablename)
840 master = resource.tablename
841
842 rfield = resource.resolve_selector(parent)
843 ltname = rfield.tname
844
845 if ltname == master:
846
847 fkey = rfield.field
848 self.__link = None
849 self.__lkey = None
850 self.__left = None
851 else:
852
853
854
855 alias = parent.split(".%s" % rfield.fname)[0]
856 calias = s3db.get_alias(master, alias)
857 if not calias:
858
859 alias = ltname.split("_", 1)[1]
860 calias = s3db.get_alias(master, alias)
861
862
863 if calias:
864 component = resource.components.get(calias)
865 link = component.link
866 if link:
867 fkey = rfield.field
868 self.__link = ltname
869 self.__lkey = link.fkey
870 self.__left = rfield.left.get(ltname)
871
872 if not fkey:
873
874 raise AttributeError("parent link not found")
875
876 if pkey is None:
877 ftype = str(fkey.type)
878 if ftype[:9] != "reference":
879
880 raise SyntaxError("Invalid parent field: "
881 "%s is not a foreign key" % fkey)
882 key = ftype[10:].split(".")
883 if key[0] == tablename:
884
885 pkey = table._id
886 else:
887
888 ktable = s3db[key[0]]
889 skey = ktable._id.name
890 if skey != "id" and "instance_type" in ktable:
891 try:
892 pkey = table[skey]
893 except AttributeError:
894 raise SyntaxError("%s is not an instance type of %s" %
895 (tablename, ktable._tablename))
896
897 self.__pkey = pkey
898 self.__fkey = fkey
899 return
900
901
939
940
941 - def postprocess_create_node(self, link, node):
942 """
943 Create a link table entry for a new node
944
945 @param link: the link information (as returned from
946 preprocess_create_node)
947 @param node: the new node
948 """
949
950 try:
951 node_id = node[self.pkey.name]
952 except (AttributeError, KeyError):
953 return
954
955 s3db = current.s3db
956 tablename = link["linktable"]
957 linktable = s3db.table(tablename)
958 if not linktable:
959 return
960
961 lkey = link["lkey"]
962 rkey = link["rkey"]
963 data = {rkey: link["parent_id"],
964 lkey: node_id,
965 }
966
967
968 query = ((linktable[lkey] == data[lkey]) &
969 (linktable[rkey] == data[rkey]))
970 row = current.db(query).select(linktable._id, limitby=(0, 1)).first()
971 if not row:
972 onaccept = s3db.get_config(tablename, "create_onaccept")
973 if onaccept is None:
974 onaccept = s3db.get_config(tablename, "onaccept")
975 link_id = linktable.insert(**data)
976 data[linktable._id.name] = link_id
977 s3db.update_super(linktable, data)
978 if link_id and onaccept:
979 callback(onaccept, Storage(vars=Storage(data)))
980 return
981
982
983 - def delete(self, node_ids, cascade=False):
984 """
985 Recursive deletion of hierarchy branches
986
987 @param node_ids: the parent node IDs of the branches to be deleted
988 @param cascade: cascade call, do not commit (internal use)
989
990 @return: number of deleted nodes, or None if cascade failed
991 """
992
993 if not self.config:
994 return None
995
996 tablename = self.tablename
997
998 total = 0
999 for node_id in node_ids:
1000
1001
1002 children = self.children(node_id)
1003 if children:
1004 result = self.delete(children, cascade=True)
1005 if result is None:
1006 if not cascade:
1007 current.db.rollback()
1008 return None
1009 else:
1010 total += result
1011
1012
1013 from s3query import FS
1014 query = (FS(self.pkey.name) == node_id)
1015 resource = current.s3db.resource(tablename, filter=query)
1016 success = resource.delete(cascade=True)
1017 if success:
1018 self.remove(node_id)
1019 total += 1
1020 else:
1021 if not cascade:
1022 current.db.rollback()
1023 return None
1024
1025 if not cascade and total:
1026 self.dirty(tablename)
1027
1028 return total
1029
1030
1031 - def add(self, node_id, parent_id=None, category=None):
1032 """
1033 Add a new node to the hierarchy
1034
1035 @param node_id: the node ID
1036 @param parent_id: the parent node ID
1037 @param category: the category
1038 """
1039
1040 theset = self.__theset
1041
1042 if node_id in theset:
1043 node = theset[node_id]
1044 if category is not None:
1045 node["c"] = category
1046 elif node_id:
1047 node = {"s": set(), "c": category}
1048 else:
1049 raise SyntaxError
1050
1051 if parent_id:
1052 if parent_id not in theset:
1053 parent = self.add(parent_id, None, None)
1054 else:
1055 parent = theset[parent_id]
1056 parent["s"].add(node_id)
1057 node["p"] = parent_id
1058
1059 theset[node_id] = node
1060 return node
1061
1062
1064 """
1065 Remove a node from the hierarchy
1066
1067 @param node_id: the node ID
1068 """
1069
1070 theset = self.__theset
1071
1072 if node_id in theset:
1073 node = theset[node_id]
1074 else:
1075 return False
1076
1077 parent_id = node["p"]
1078 if parent_id:
1079 parent = theset[parent_id]
1080 parent["s"].discard(node_id)
1081 del theset[node_id]
1082 return True
1083
1084
1086 """ Generate the subset of accessible nodes which match the filter """
1087
1088 theset = self.theset
1089
1090 roots = set()
1091 subset = {}
1092
1093 resource = current.s3db.resource(self.tablename,
1094 filter = self.filter)
1095
1096 pkey = self.pkey
1097 rows = resource.select([pkey.name], as_rows = True)
1098
1099 if rows:
1100 key = str(pkey)
1101 if self.leafonly:
1102
1103 ids = set()
1104 for row in rows:
1105 node_id = row[key]
1106 if node_id in theset and not theset[node_id]["s"]:
1107 ids.add(node_id)
1108 else:
1109
1110 ids = set(row[key] for row in rows)
1111
1112
1113 while ids:
1114 node_id = ids.pop()
1115 if node_id in subset:
1116 continue
1117 node = theset.get(node_id)
1118 if not node:
1119 continue
1120 parent_id = node["p"]
1121 if parent_id and parent_id not in subset:
1122 ids.add(parent_id)
1123 elif not parent_id:
1124 roots.add(node_id)
1125 subset[node_id] = dict(node)
1126
1127
1128 for node in subset.values():
1129 node["s"] = set(node_id for node_id in node["s"]
1130 if node_id in subset)
1131
1132 self.__roots = roots
1133 self.__nodes = subset
1134 return
1135
1136
1138 """
1139 Get the category of a node
1140
1141 @param node_id: the node ID
1142
1143 @return: the node category
1144 """
1145
1146 node = self.nodes.get(node_id)
1147 if not node:
1148 return None
1149 else:
1150 return node["c"]
1151
1152
1153 - def parent(self, node_id, classify=False):
1154 """
1155 Get the parent node of a node
1156
1157 @param node_id: the node ID
1158 @param classify: return the root node as tuple (id, category)
1159 instead of just id
1160
1161 @return: the root node ID (or tuple (id, category), respectively)
1162 """
1163
1164 nodes = self.nodes
1165
1166 default = (None, None) if classify else None
1167
1168 node = nodes.get(node_id)
1169 if not node:
1170 return default
1171
1172 parent_id = node["p"]
1173 if not parent_id:
1174 return default
1175
1176 parent_node = nodes.get(parent_id)
1177 if not parent_node:
1178 return default
1179
1180 parent_category = parent_node["c"]
1181 return (parent_id, parent_category) if classify else parent_id
1182
1183
1185 """
1186 Get child nodes of a node
1187
1188 @param node_id: the node ID
1189 @param category: return only children of this category
1190 @param classify: return each node as tuple (id, category) instead
1191 of just ids
1192
1193 @return: the child nodes as Python set
1194 """
1195
1196 nodes = self.nodes
1197 default = set()
1198
1199 node = nodes.get(node_id)
1200 if not node:
1201 return default
1202
1203 child_ids = node["s"]
1204 if not child_ids:
1205 return default
1206
1207 children = set()
1208 for child_id in child_ids:
1209 child_node = nodes.get(child_id)
1210 if not child_node:
1211 continue
1212 child_category = child_node["c"]
1213 child = (child_id, child_category) if classify else child_id
1214 if category is DEFAULT or category == child_category:
1215 children.add(child)
1216 return children
1217
1218
1219 - def path(self, node_id, category=DEFAULT, classify=False):
1220 """
1221 Return the ancestor path of a node
1222
1223 @param node_id: the node ID
1224 @param category: start with this category rather than with root
1225 @param classify: return each node as tuple (id, category) instead
1226 of just ids
1227
1228 @return: the path as list, starting at the root node
1229 """
1230
1231 nodes = self.nodes
1232
1233 node = nodes.get(node_id)
1234 if not node:
1235 return []
1236 this = (node_id, node["c"]) if classify else node_id
1237 if category is not DEFAULT and node["c"] == category:
1238 return [this]
1239 parent_id = node["p"]
1240 if not parent_id:
1241 return [this]
1242 path = self.path(parent_id, category=category, classify=classify)
1243 path.append(this)
1244 return path
1245
1246
1247 - def root(self, node_id, category=DEFAULT, classify=False):
1248 """
1249 Get the root node for a node. Returns the node if it is the
1250 root node itself.
1251
1252 @param node_id: the node ID
1253 @param category: find the closest node of this category rather
1254 than the absolute root
1255 @param classify: return the root node as tuple (id, category)
1256 instead of just id
1257
1258 @return: the root node ID (or tuple (id, category), respectively)
1259 """
1260
1261 nodes = self.nodes
1262 default = (None, None) if classify else None
1263
1264 node = nodes.get(node_id)
1265 if not node:
1266 return default
1267 this = (node_id, node["c"]) if classify else node_id
1268 if category is not DEFAULT and node["c"] == category:
1269 return this
1270 parent_id = node["p"]
1271 if not parent_id:
1272 return this if category is DEFAULT else default
1273 return self.root(parent_id, category=category, classify=classify)
1274
1275
1276 - def depth(self, node_id, level=0):
1277 """
1278 Determine the depth of a hierarchy
1279
1280 @param node_id: the start node (default to all root nodes)
1281 """
1282
1283 nodes = self.nodes
1284 depth = self.depth
1285
1286 node = nodes.get(node_id)
1287 if not node:
1288 roots = self.roots
1289 result = max(depth(root) for root in roots)
1290 else:
1291 children = node["s"]
1292 if children:
1293 result = max(depth(n, level=level+1) for n in children)
1294 else:
1295 result = level
1296 return result
1297
1298
1299 - def siblings(self,
1300 node_id,
1301 category=DEFAULT,
1302 classify=False,
1303 inclusive=False):
1304 """
1305 Get the sibling nodes of a node. If the node is a root node,
1306 this method returns all root nodes.
1307
1308 @param node_id: the node ID
1309 @param category: return only nodes of this category
1310 @param classify: return each node as tuple (id, category)
1311 instead of just id
1312 @param inclusive: include the start node
1313
1314 @param return: a set of node IDs
1315 (or tuples (id, category), respectively)
1316 """
1317
1318 result = set()
1319 nodes = self.nodes
1320
1321 node = nodes.get(node_id)
1322 if not node:
1323 return result
1324
1325 parent_id = node["p"]
1326 if not parent_id:
1327 siblings = [(k, nodes[k]) for k in self.roots]
1328 else:
1329 parent = nodes[parent_id]
1330 if parent["s"]:
1331 siblings = [(k, nodes[k]) for k in parent["s"]]
1332 else:
1333 siblings = []
1334
1335 add = result.add
1336 for sibling_id, sibling_node in siblings:
1337 if not inclusive and sibling_id == node_id:
1338 continue
1339 if category is DEFAULT or category == sibling_node["c"]:
1340 sibling = (sibling_id, sibling_node["c"]) \
1341 if classify else sibling_id
1342 add(sibling)
1343 return result
1344
1345
1346 - def findall(self,
1347 node_id,
1348 category=DEFAULT,
1349 classify=False,
1350 inclusive=False):
1351 """
1352 Find descendant nodes of a node
1353
1354 @param node_id: the node ID (can be an iterable of node IDs)
1355 @param category: find nodes of this category
1356 @param classify: return each node as tuple (id, category) instead
1357 of just ids
1358 @param inclusive: include the start node(s) if they match
1359
1360 @return: a set of node IDs (or tuples (id, category), respectively)
1361 """
1362
1363 result = set()
1364 findall = self.findall
1365 if isinstance(node_id, (set, list, tuple)):
1366 for n in node_id:
1367 if n is None:
1368 continue
1369 result |= findall(n,
1370 category=category,
1371 classify=classify,
1372 inclusive=inclusive)
1373 return result
1374 nodes = self.nodes
1375 node = nodes.get(node_id)
1376 if not node:
1377 return result
1378 if node["s"]:
1379 result |= findall(node["s"],
1380 category=category,
1381 classify=classify,
1382 inclusive=True)
1383 if inclusive:
1384 this = (node_id, node["c"]) if classify else node_id
1385 if category is DEFAULT or category == node["c"]:
1386 result.add(this)
1387 return result
1388
1389
1390 - def _represent(self, node_ids=None, renderer=None):
1391 """
1392 Represent nodes as labels, the labels are stored in the
1393 nodes as attribute "l".
1394
1395 @param node_ids: the node IDs (None for all nodes)
1396 @param renderer: the representation method (falls back
1397 to the "name" field in the target table
1398 if present)
1399 """
1400
1401 theset = self.theset
1402 LABEL = "l"
1403
1404 if node_ids is None:
1405 node_ids = self.nodes.keys()
1406
1407 pending = set()
1408 for node_id in node_ids:
1409 node = theset.get(node_id)
1410 if not node:
1411 continue
1412 if LABEL not in node:
1413 pending.add(node_id)
1414
1415 if renderer is None:
1416 renderer = self.represent
1417 if renderer is None:
1418 tablename = self.tablename
1419 table = current.s3db.table(tablename) if tablename else None
1420 if table and "name" in table.fields:
1421 from s3fields import S3Represent
1422 self.represent = renderer = S3Represent(lookup = tablename,
1423 key = self.pkey.name)
1424 else:
1425 renderer = s3_str
1426 if hasattr(renderer, "bulk"):
1427 labels = renderer.bulk(list(pending), list_type = False)
1428 for node_id, label in labels.items():
1429 if node_id in theset:
1430 theset[node_id][LABEL] = label
1431 else:
1432 for node_id in pending:
1433 try:
1434 label = renderer(node_id)
1435 except:
1436 label = s3_str(node_id)
1437 theset[node_id][LABEL] = label
1438 return
1439
1440
1441 - def label(self, node_id, represent=None):
1442 """
1443 Get a label for a node
1444
1445 @param node_id: the node ID
1446 @param represent: the node ID representation method
1447 """
1448
1449 LABEL = "l"
1450
1451 theset = self.theset
1452 node = theset.get(node_id)
1453 if node:
1454 if LABEL in node:
1455 label = node[LABEL]
1456 else:
1457 self._represent(node_ids=[node_id], renderer=represent)
1458 if LABEL in node:
1459 label = node[LABEL]
1460 if type(label) is unicode:
1461 try:
1462 label = label.encode("utf-8")
1463 except UnicodeEncodeError:
1464 pass
1465 return label
1466 return None
1467
1468
1469 - def _json(self, node_id, represent=None, depth=0, max_depth=None):
1470 """
1471 Represent a node as JSON-serializable array
1472
1473 @param node_id: the node ID
1474 @param represent: the representation method
1475 @param depth: the current recursion depth
1476 @param max_depth: the maximum recursion depth
1477
1478 @returns: the node as [label, category, subnodes],
1479 with subnodes as:
1480 - False: if there are no subnodes
1481 - True: if there are subnodes beyond max_depth
1482 - otherwise: {node_id: [label, category, subnodes], ...}
1483 """
1484
1485 node = self.nodes.get(node_id)
1486 if not node:
1487 return None
1488
1489 label = self.label(node_id, represent=represent)
1490 if label is None:
1491 label = node_id
1492
1493 category = node["c"]
1494 subnode_ids = node["s"]
1495 if subnode_ids:
1496 if max_depth and depth == max_depth:
1497 subnodes = True
1498 else:
1499 subnodes = {}
1500 for subnode_id in subnode_ids:
1501 item = self._json(subnode_id,
1502 represent = represent,
1503 depth = depth + 1,
1504 max_depth = max_depth,
1505 )
1506 if item:
1507 subnodes[subnode_id] = item
1508 else:
1509 subnodes = False
1510
1511 return [s3_str(label), category, subnodes]
1512
1513
1514 - def json(self, root=None, represent=None, max_depth=None):
1515 """
1516 Represent the hierarchy as JSON-serializable dict
1517
1518 @param root: the root node ID (or array of root node IDs)
1519 @param represent: the representation method
1520 @param max_depth: maximum recursion depth
1521
1522 @returns: the hierarchy as dict:
1523 {node_id: [label, category, subnodes], ...}
1524 """
1525
1526 self._represent(renderer=represent)
1527
1528 roots = [root] if root else self.roots
1529
1530 output = {}
1531 for node_id in roots:
1532 item = self._json(node_id,
1533 represent = represent,
1534 max_depth = max_depth,
1535 )
1536 if item:
1537 output[node_id] = item
1538
1539 return output
1540
1541
1542 - def html(self,
1543 widget_id,
1544 root=None,
1545 represent=None,
1546 hidden=True,
1547 none=None,
1548 _class=None):
1549 """
1550 Render this hierarchy as nested unsorted list
1551
1552 @param widget_id: a unique ID for the HTML widget
1553 @param root: node ID of the start node (defaults to all
1554 available root nodes)
1555 @param represent: the representation method for the node IDs
1556 @param hidden: render with style display:none
1557 @param _class: the HTML class for the outermost list
1558
1559 @return: the list (UL)
1560 """
1561
1562 self._represent(renderer=represent)
1563
1564 roots = [root] if root else self.roots
1565
1566 html = self._html
1567 output = UL([html(node_id, widget_id, represent=represent)
1568 for node_id in roots],
1569 _id=widget_id,
1570 _style="display:none" if hidden else None)
1571 if _class:
1572 output.add_class(_class)
1573 if none:
1574 if none is True:
1575 none = current.messages["NONE"]
1576 output.insert(0, LI(none,
1577 _id="%s-None" % widget_id,
1578 _rel = "none",
1579 _class = "s3-hierarchy-node s3-hierarchy-none",
1580 ))
1581 return output
1582
1583
1584 - def _html(self, node_id, widget_id, represent=None):
1585 """
1586 Recursively render a node as list item (with subnodes
1587 as unsorted list inside the item)
1588
1589 @param node_id: the node ID
1590 @param widget_id: the unique ID for the outermost list
1591 @param represent: the node ID representation method
1592
1593 @return: the list item (LI)
1594
1595 @todo: option to add CRUD permissions
1596 """
1597
1598 node = self.nodes.get(node_id)
1599 if not node:
1600 return None
1601
1602 label = self.label(node_id, represent=represent)
1603 if label is None:
1604 label = s3_str(node_id)
1605
1606 subnodes = node["s"]
1607 item = LI(label,
1608 _id = "%s-%s" % (widget_id, node_id),
1609 _rel = "parent" if subnodes else "leaf",
1610 _class = "s3-hierarchy-node",
1611 )
1612
1613 html = self._html
1614 if subnodes:
1615 sublist = UL([html(n, widget_id, represent=represent)
1616 for n in subnodes])
1617 item.append(sublist)
1618 return item
1619
1620
1621 - def export_node(self,
1622 node_id,
1623 prefix = "_hierarchy",
1624 depth=None,
1625 level=0,
1626 path=None,
1627 hcol=None,
1628 columns = None,
1629 data = None,
1630 node_list=None):
1631 """
1632 Export the hierarchy beneath a node
1633
1634 @param node_id: the root node
1635 @param prefix: prefix for the hierarchy column in the output
1636 @param depth: the maximum depth to export
1637 @param level: the current recursion level (internal)
1638 @param path: the path dict for this node (internal)
1639 @param hcol: the hierarchy column in the input data
1640 @param columns: the list of columns to export
1641 @param data: the input data dict {node_id: row}
1642 @param node_list: the output data list (will be appended to)
1643
1644 @returns: the output data list
1645
1646 @todo: pass the input data as list and retain the original
1647 order when recursing into child nodes?
1648 """
1649
1650 if node_list is None:
1651 node_list = []
1652
1653
1654 if depth is not None and level > depth:
1655 return node_list
1656
1657
1658 node = self.nodes.get(node_id)
1659 if not node:
1660 return node_list
1661
1662
1663 if data:
1664 if node_id not in data:
1665 return node_list
1666 node_data = data.get(node_id)
1667 else:
1668 node_data = {}
1669
1670
1671 if path is None:
1672 if depth is None:
1673 depth = self.depth(node_id)
1674 path = dict(("%s.%s" % (prefix, l), "") for l in xrange(depth+1))
1675
1676
1677 label = node_data.get(hcol) if hcol else node_id
1678 path["%s.%s" % (prefix, level)] = label
1679
1680
1681 record = dict(path)
1682 if columns:
1683 for column in columns:
1684 if columns == hcol:
1685 continue
1686 record[column] = node_data.get(column)
1687
1688
1689 node_list.append(record)
1690
1691
1692 children = node["s"]
1693 for child in children:
1694 self.export_node(child,
1695 prefix = prefix,
1696 depth=depth,
1697 level=level+1,
1698 path=dict(path),
1699 hcol=hcol,
1700 columns=columns,
1701 data=data,
1702 node_list=node_list,
1703 )
1704 return node_list
1705
1706
1707