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

Source Code for Module s3.s3hierarchy

   1  # -*- coding: utf-8 -*- 
   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 
44 45 # ============================================================================= 46 -class S3HierarchyCRUD(S3Method):
47 """ Method handler for hierarchical CRUD """ 48 49 # -------------------------------------------------------------------------
50 - def apply_method(self, r, **attr):
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 # Widget ID 87 widget_id = "%s-hierarchy" % tablename 88 89 # Render the tree 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 # Page title 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 # Build the form 103 form = FORM(DIV(tree, 104 _class="s3-hierarchy-tree", 105 ), 106 _id = widget_id, 107 ) 108 output["form"] = form 109 110 # Widget options and scripts 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 # Check permissions and add CRUD URLs 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 # Theme options 155 theme = current.deployment_settings.get_ui_hierarchy_theme() 156 icons = theme.get("icons", False) 157 if icons: 158 # Only include non-default options 159 widget_opts["icons"] = icons 160 stripes = theme.get("stripes", True) 161 if not stripes: 162 # Only include non-default options 163 widget_opts["stripes"] = stripes 164 self.include_scripts(widget_id, widget_opts) 165 166 # View 167 current.response.view = self._view(r, "hierarchy.html") 168 169 return output
170 171 # -------------------------------------------------------------------------
172 - def node_json(self, r, **attr):
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 # @todo: include CRUD permissions? 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 # -------------------------------------------------------------------------
216 - def render_tree(self, widget_id, record=None):
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 # Hierarchy misconfigured? Or has r.record been tampered with? 237 msg = "S3Hierarchy: key %s not found in record" % h.pkey 238 e.args = tuple([msg] + list(e.args[1:])) 239 raise 240 241 # @todo: apply all resource filters? 242 return h.html("%s-tree" % widget_id, root=root)
243 244 # ------------------------------------------------------------------------- 245 @staticmethod
246 - def include_scripts(widget_id, widget_opts):
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 # Include static scripts & stylesheets 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 # Apply the widget JS 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 # -------------------------------------------------------------------------
283 - def export_xls(self, r, **attr):
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 # Get the hierarchy 314 h = S3Hierarchy(tablename=tablename) 315 if not h.config: 316 r.error(405, "No hierarchy configured for %s" % tablename) 317 318 # Intepret the hierarchy_export setting for the resource 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 # Get the list fields 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 # Selectors = the fields to extract 340 selectors = [h.pkey.name, rfield.selector] 341 342 # Columns = the keys for the XLS Codec to access the rows 343 # Types = the data types of the columns (in same order!) 344 columns = [] 345 types = [] 346 347 # Generate the headers and type list for XLS Codec 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 # Get the root nodes 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 # Find all child nodes 374 all_nodes = h.findall(roots, inclusive=True) 375 376 # ...and extract their data from a clone of the resource 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 # Convert into dict {hierarchy key: row} 383 hkey = str(h.pkey) 384 data_dict = dict((row._row[hkey], row) for row in data.rows) 385 386 # Add hierarchy headers and types 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 # Generate the output for XLS Codec 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 # Encode in XLS format 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 # Reponse headers and file name are set in codec 424 return result
425
426 # ============================================================================= 427 -class S3Hierarchy(object):
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
474 - def theset(self):
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
492 - def flags(self):
493 """ Dict of status flags """ 494 495 if self.__flags is None: 496 theset = self.theset 497 return self.__flags
498 499 # ------------------------------------------------------------------------- 500 @property
501 - def config(self):
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
514 - def nodes(self):
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
524 - def roots(self):
525 """ The IDs of the root nodes in the subset """ 526 527 nodes = self.nodes 528 return self.__roots
529 530 # ------------------------------------------------------------------------- 531 @property
532 - def pkey(self):
533 """ The parent key """ 534 535 if self.__pkey is None: 536 self.__keys() 537 return self.__pkey
538 539 # ------------------------------------------------------------------------- 540 @property
541 - def fkey(self):
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 559 560 # ------------------------------------------------------------------------- 561 @property
562 - def lkey(self):
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
571 - def left(self):
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
580 - def ckey(self):
581 """ The category field """ 582 583 if self.__ckey is None: 584 self.__keys() 585 return self.__ckey
586 587 # -------------------------------------------------------------------------
588 - def __connect(self):
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 # -------------------------------------------------------------------------
634 - def load(self):
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 # Cancel attempt if DB is known to be dirty 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 # -------------------------------------------------------------------------
671 - def save(self):
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 # Serialize the theset 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 # Generate record 691 data = {"tablename": tablename, 692 "dirty": False, 693 "hierarchy": {"nodes": nodes_dict} 694 } 695 696 # Get current entry 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 # Update record 704 row.update_record(**data) 705 else: 706 # Create new record 707 htable.insert(**data) 708 709 # Update status 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 # -------------------------------------------------------------------------
756 - def read(self):
757 """ Rebuild this hierarchy from the target table """ 758 759 tablename = self.tablename 760 if not tablename: 761 return 762 763 s3db = current.s3db 764 table = s3db[tablename] 765 766 pkey = self.pkey 767 fkey = self.fkey 768 ckey = self.ckey 769 770 fields = [pkey, fkey] 771 if ckey is not None: 772 fields.append(table[ckey]) 773 774 if "deleted" in table: 775 query = (table.deleted != True) 776 else: 777 query = (table.id > 0) 778 rows = current.db(query).select(left = self.left, *fields) 779 780 self.__theset.clear() 781 782 add = self.add 783 cfield = table[ckey] 784 for row in rows: 785 n = row[pkey] 786 p = row[fkey] 787 if ckey: 788 c = row[cfield] 789 else: 790 c = None 791 add(n, parent_id=p, category=c) 792 793 # Update status: memory is clean, db needs update 794 self.__status(dirty=False, dbupdate=True) 795 796 # Remove subset 797 self.__roots = None 798 self.__nodes = None 799 800 return
801 802 # -------------------------------------------------------------------------
803 - def __keys(self):
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 # Assume self-reference 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 # Self-reference 847 fkey = rfield.field 848 self.__link = None 849 self.__lkey = None 850 self.__left = None 851 else: 852 # Link table 853 854 # Use the parent selector to find the link resource 855 alias = parent.split(".%s" % rfield.fname)[0] 856 calias = s3db.get_alias(master, alias) 857 if not calias: 858 # Fall back to link table name 859 alias = ltname.split("_", 1)[1] 860 calias = s3db.get_alias(master, alias) 861 862 # Load the component and get the link parameters 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 # No parent field found 874 raise AttributeError("parent link not found") 875 876 if pkey is None: 877 ftype = str(fkey.type) 878 if ftype[:9] != "reference": 879 # Invalid parent field (not a foreign key) 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 # Self-reference 885 pkey = table._id 886 else: 887 # Super-entity? 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 # -------------------------------------------------------------------------
902 - def preprocess_create_node(self, r, parent_id):
903 """ 904 Pre-process a CRUD request to create a new node 905 906 @param r: the request 907 @param table: the hierarchical table 908 @param parent_id: the parent ID 909 """ 910 911 # Make sure the parent record exists 912 table = current.s3db.table(self.tablename) 913 query = (table[self.pkey.name] == parent_id) 914 DELETED = current.xml.DELETED 915 if DELETED in table.fields: 916 query &= table[DELETED] != True 917 parent = current.db(query).select(table._id).first() 918 if not parent: 919 raise KeyError("Parent record not found") 920 921 link = self.link 922 fkey = self.fkey 923 if self.link is None: 924 # Parent field in table 925 fkey.default = fkey.update = parent_id 926 fkey.comment = None 927 if r.http == "POST": 928 r.post_vars[fkey.name] = parent_id 929 fkey.readable = fkey.writable = False 930 link = None 931 else: 932 # Parent field in link table 933 link = {"linktable": self.link, 934 "rkey": fkey.name, 935 "lkey": self.lkey, 936 "parent_id": parent_id, 937 } 938 return link
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 # Create the link if it does not already exist 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 # Recursively delete child nodes 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 # Delete node 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 # -------------------------------------------------------------------------
1063 - def remove(self, node_id):
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 # -------------------------------------------------------------------------
1085 - def __subset(self):
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 # Select matching leaf nodes 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 # Select all matching nodes 1110 ids = set(row[key] for row in rows) 1111 1112 # Resolve the paths 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 # Update the descendants 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 # -------------------------------------------------------------------------
1137 - def category(self, node_id):
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 # -------------------------------------------------------------------------
1184 - def children(self, node_id, category=DEFAULT, classify=False):
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 # Do not recurse deeper than depth levels below the root node 1654 if depth is not None and level > depth: 1655 return node_list 1656 1657 # Get the current node 1658 node = self.nodes.get(node_id) 1659 if not node: 1660 return node_list 1661 1662 # Get the node data 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 # Generate the path dict if it doesn't exist yet 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 # Set the hierarchy column 1677 label = node_data.get(hcol) if hcol else node_id 1678 path["%s.%s" % (prefix, level)] = label 1679 1680 # Add remaining columns to the record dict 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 # Append the record to the node list 1689 node_list.append(record) 1690 1691 # Recurse into child nodes 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 # END ========================================================================= 1707