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

Source Code for Module s3.s3delete

  1  # -*- coding: utf-8 -*- 
  2   
  3  """ S3 Record Deletion 
  4   
  5      @copyright: 2018-2019 (c) Sahana Software Foundation 
  6      @license: MIT 
  7   
  8      Permission is hereby granted, free of charge, to any person 
  9      obtaining a copy of this software and associated documentation 
 10      files (the "Software"), to deal in the Software without 
 11      restriction, including without limitation the rights to use, 
 12      copy, modify, merge, publish, distribute, sublicense, and/or sell 
 13      copies of the Software, and to permit persons to whom the 
 14      Software is furnished to do so, subject to the following 
 15      conditions: 
 16   
 17      The above copyright notice and this permission notice shall be 
 18      included in all copies or substantial portions of the Software. 
 19   
 20      THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 
 21      EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 
 22      OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 
 23      NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 
 24      HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 
 25      WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 
 26      FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 
 27      OTHER DEALINGS IN THE SOFTWARE. 
 28  """ 
 29   
 30  import json 
 31  import sys 
 32   
 33  from gluon import current 
 34  from gluon.tools import callback 
 35   
 36  from s3dal import original_tablename, Row 
 37  from s3utils import s3_get_last_record_id, s3_has_foreign_key, s3_remove_last_record_id 
 38   
 39  __all__ = ("S3Delete", 
 40             ) 
 41   
 42  DELETED = "deleted" 
43 44 # ============================================================================= 45 -class S3Delete(object):
46 """ 47 Process to delete/archive records in a S3Resource 48 """ 49
50 - def __init__(self, resource, archive=None, representation=None):
51 """ 52 Constructor 53 54 @param resource: the S3Resource to delete records from 55 @param archive: True|False to override global 56 security.archive_not_delete setting 57 @param representation: the request format (for audit, optional) 58 """ 59 60 self.resource = resource 61 self.representation = representation 62 63 # Get unaliased table 64 tablename = self.tablename = original_tablename(resource.table) 65 table = self.table = current.db[tablename] 66 67 # Archive or hard-delete? 68 if archive is None: 69 if current.deployment_settings.get_security_archive_not_delete() and \ 70 DELETED in table: 71 archive = True 72 else: 73 archive = False 74 self.archive = archive 75 76 # Callbacks 77 get_config = resource.get_config 78 self.prepare = get_config("ondelete_cascade") 79 self.ondelete = get_config("ondelete") 80 81 # Initialize properties 82 self._super_keys = None 83 self._foreign_keys = None 84 self._references = None 85 self._restrictions = None 86 87 self._done = False 88 89 # Initialize instance variables 90 self.errors = {} 91 self.permission_error = False
92 93 # -------------------------------------------------------------------------
94 - def __call__(self, cascade=False, replaced_by=None, skip_undeletable=False):
95 """ 96 Main deletion process, deletes/archives all records 97 in the resource 98 99 @param cascade: this is called as a cascade-action from another 100 process (e.g. another delete) 101 @param skip_undeletable: delete whatever is possible, skip 102 undeletable rows 103 @param replaced_by: dict of {replaced_id: replacement_id}, 104 used by record merger to log which record 105 has replaced which 106 """ 107 108 # Must not re-use instance 109 if self._done: 110 raise RuntimeError("deletion already processed") 111 self._done = True 112 113 tablename = self.tablename 114 115 # Check the entire cascade, rather than breaking out after the 116 # first error - debug-only, this can be many errors 117 # (NB ?debug=1 alone won't help if logging is off in 000_config.py) 118 check_all = current.response.s3.debug 119 120 # Look up all rows that are to be deleted 121 rows = self.extract() 122 if not rows: 123 # No rows to delete 124 # => not an error, but log anyway to assist caller debugging 125 if not cascade: 126 current.log.debug("Delete %s: no rows found" % tablename) 127 return 0 128 else: 129 first = rows[0] 130 if hasattr(first, tablename) and isinstance(first[tablename], Row): 131 # Rows are the result of a join (due to extra_fields) 132 joined = True 133 else: 134 joined = False 135 136 table = self.table 137 pkey = table._id.name 138 139 add_error = self.add_error 140 141 # Check permissions and prepare records 142 has_permission = current.auth.s3_has_permission 143 prepare = self.prepare 144 145 records = [] 146 for row in rows: 147 148 record = getattr(row, tablename) if joined else row 149 record_id = record[pkey] 150 151 # Check permissions 152 if not has_permission("delete", table, record_id=record_id): 153 self.permission_error = True 154 add_error(record_id, "not permitted") 155 continue 156 157 # Run table-specific ondelete-cascade 158 if prepare: 159 try: 160 callback(prepare, record, tablename=tablename) 161 except Exception: 162 # Exception indicates record is undeletable 163 add_error(record_id, sys.exc_info()[1]) 164 continue 165 166 records.append(record) 167 168 # Identify deletable records 169 deletable = self.check_deletable(records, check_all=check_all) 170 171 # If on cascade or not skipping undeletable rows: exit immediately 172 if self.errors and (cascade or not skip_undeletable): 173 self.set_resource_error() 174 if not cascade: 175 self.log_errors() 176 return 0 177 178 # Delete the records 179 db = current.db 180 181 audit = current.audit 182 resource = self.resource 183 prefix, name = resource.prefix, resource.name 184 185 ondelete = self.ondelete 186 delete_super = current.s3db.delete_super 187 188 num_deleted = 0 189 for row in deletable: 190 191 record_id = row[pkey] 192 success = True 193 194 if self.archive: 195 # Run automatic deletion cascade 196 success = self.cascade(row, check_all=check_all) 197 198 if success: 199 # Unlink all super-records 200 success = delete_super(table, row) 201 if not success: 202 add_error(record_id, "super-entity deletion failed") 203 204 if success: 205 # Auto-delete linked record if appropriate 206 self.auto_delete_linked(row) 207 208 # Archive/delete the row itself 209 if self.archive: 210 success = self.archive_record(row, replaced_by=replaced_by) 211 else: 212 success = self.delete_record(row) 213 214 if success: 215 # Postprocess delete 216 217 # Clear session 218 if s3_get_last_record_id(tablename) == record_id: 219 s3_remove_last_record_id(tablename) 220 221 # Audit 222 audit("delete", prefix, name, 223 record = record_id, 224 representation = self.representation, 225 ) 226 227 # On-delete hook 228 if ondelete: 229 callback(ondelete, row) 230 231 # Subsequent cascade errors would roll back successful 232 # deletions too => we want to prevent that when skipping 233 # undeletable rows, so commit here if this is the master 234 # process 235 if not cascade and skip_undeletable: 236 db.commit() 237 238 num_deleted += 1 239 240 elif not cascade: 241 # Master process failure 242 db.rollback() 243 self.log_errors() 244 245 if skip_undeletable: 246 # Try next row 247 continue 248 else: 249 # Exit immediately 250 break 251 else: 252 # Cascade failure, no point to try any other row 253 # - will be rolled back by master process 254 break 255 256 self.set_resource_error() 257 return num_deleted
258 259 # -------------------------------------------------------------------------
260 - def extract(self):
261 """ 262 Extract the rows to be deleted 263 264 @returns: a Rows instance 265 """ 266 267 table = self.table 268 269 # Determine which fields to extract 270 fields = [table._id.name] 271 if "uuid" in table.fields: 272 fields.append("uuid") 273 fields.extend(list(set(self.super_keys) | set(self.foreign_keys))) 274 275 # Extract the records (as Rows) 276 rows = self.resource.select(fields, 277 limit = None, 278 as_rows = True, 279 ) 280 281 return rows
282 283 # -------------------------------------------------------------------------
284 - def check_deletable(self, rows, check_all=False):
285 """ 286 Check which rows in the set are deletable, collect all errors 287 288 @param rows: the Rows to be deleted 289 @param check_all: find all restrictions for each record 290 rather than from just one table (not 291 standard because of performance cost) 292 293 @returns: array of Rows found to be deletable 294 NB those can still fail further down the cascade 295 """ 296 297 db = current.db 298 299 tablename = self.tablename 300 table = self.table 301 pkey = table._id.name 302 303 deletable = set() 304 for row in rows: 305 record_id = row[pkey] 306 deletable.add(record_id) 307 308 if self.archive: 309 310 add_error = self.add_error 311 errors = {} 312 313 record_ids = set(deletable) if check_all else deletable 314 for restriction in self.restrictions: 315 316 tn = restriction.tablename 317 rtable = db[tn] 318 rtable_id = rtable._id 319 320 query = (restriction.belongs(record_ids)) 321 if tn == tablename: 322 query &= (restriction != rtable_id) 323 if DELETED in rtable: 324 query &= (rtable[DELETED] == False) 325 326 count = rtable_id.count() 327 rrows = db(query).select(count, 328 restriction, 329 groupby = restriction, 330 ) 331 332 fname = str(restriction) 333 for rrow in rrows: 334 # Collect errors per restricted record 335 restricted = rrow[restriction] 336 if restricted in errors: 337 restrictions = errors[restricted] 338 else: 339 restrictions = errors[restricted] = {} 340 restrictions[fname] = rrow[count] 341 342 # Remove restricted record from deletables 343 deletable.discard(restricted) 344 345 # Aggregate all errors 346 if errors: 347 for record_id, restrictions in errors.items(): 348 msg = ", ".join("%s (%s records)" % (k, v) 349 for k, v in restrictions.items() 350 ) 351 add_error(record_id, "restricted by %s" % msg) 352 353 354 return [row for row in rows if row[pkey] in deletable]
355 356 # -------------------------------------------------------------------------
357 - def cascade(self, row, check_all=False):
358 """ 359 Run the automatic deletion cascade: remove or update records 360 referencing this row with ondelete!="RESTRICT" 361 362 @param row: the Row to delete 363 @param check_all: process the entire cascade to reveal all 364 errors (rather than breaking out of it after 365 the first error) 366 """ 367 368 tablename = self.tablename 369 table = self.table 370 pkey = table._id.name 371 record_id = row[pkey] 372 373 success = True 374 375 db = current.db 376 define_resource = current.s3db.resource 377 add_error = self.add_error 378 379 references = self.references 380 for reference in references: 381 382 fn = reference.name 383 tn = reference.tablename 384 rtable = db[tn] 385 386 query = (reference == record_id) 387 if tn == tablename: 388 query &= (reference != rtable._id) 389 390 ondelete = reference.ondelete 391 if ondelete == "CASCADE": 392 # NB permission check on target included, i.e. the right 393 # to delete a record does not imply the right to remove 394 # records referencing it 395 rresource = define_resource(tn, 396 filter = query, 397 unapproved = True, 398 ) 399 delete = S3Delete(rresource, 400 archive = self.archive, 401 representation = self.representation, 402 ) 403 delete(cascade=True) 404 if delete.errors: 405 success = False 406 add_error(record_id, delete.errors) 407 if check_all: 408 continue 409 else: 410 break 411 else: 412 # NB no permission check on target here, i.e. the right 413 # to delete a record overrides the right to keep an 414 # annullable reference to it 415 if ondelete == "SET NULL": 416 default = None 417 elif ondelete == "SET DEFAULT": 418 default = reference.default 419 else: 420 continue 421 422 if DELETED in rtable.fields: 423 query &= rtable[DELETED] == False 424 try: 425 db(query).update(**{fn: default}) 426 except Exception: 427 success = False 428 add_error(record_id, sys.exc_info()[1]) 429 if check_all: 430 continue 431 else: 432 break 433 434 return success
435 436 # -------------------------------------------------------------------------
437 - def auto_delete_linked(self, row):
438 """ 439 Auto-delete linked records if row was the last link 440 441 @param row: the Row about to get deleted 442 """ 443 444 resource = self.resource 445 linked = resource.linked 446 447 if linked and resource.autodelete and linked.autodelete: 448 449 table = self.table 450 rkey = linked.rkey 451 if rkey in row: 452 this_rkey = row[rkey] 453 454 # Check for other links to the same linked record 455 query = (table._id != row[table._id.name]) & \ 456 (table[rkey] == this_rkey) 457 if DELETED in table: 458 query &= (table[DELETED] != True) 459 remaining = current.db(query).select(table._id, 460 limitby = (0, 1), 461 ).first() 462 if not remaining: 463 # Try to delete the linked record 464 s3db = current.s3db 465 fkey = linked.fkey 466 linked_table = s3db.table(linked.tablename) 467 query = (linked_table[fkey] == this_rkey) 468 linked = s3db.resource(linked_table, 469 filter = query, 470 unapproved = True, 471 ) 472 delete = S3Delete(linked, 473 archive = self.archive, 474 representation = self.representation, 475 ) 476 delete(cascade=True) 477 if delete.errors: 478 delete.log_errors()
479 480 # ------------------------------------------------------------------------- 481 # Record Archiving/Deletion 482 # -------------------------------------------------------------------------
483 - def archive_record(self, row, replaced_by=None):
484 """ 485 Archive ("soft-delete") a record 486 487 @param row: the Row to delete 488 @param replaced_by: dict of {replaced_id: replacement_id}, 489 used by record merger to log which record 490 has replaced which 491 492 @returns: True for success, False on error 493 """ 494 495 table = self.table 496 table_fields = table.fields 497 498 record_id = row[table._id.name] 499 data = {"deleted": True} 500 501 # Reset foreign keys to resolve constraints 502 fk = {} 503 for fname in self.foreign_keys: 504 value = row[fname] 505 if value: 506 fk[fname] = value 507 if not table[fname].notnull: 508 data[fname] = None 509 if fk and "deleted_fk" in table_fields: 510 # Remember any deleted foreign keys 511 data["deleted_fk"] = json.dumps(fk) 512 513 # Remember the replacement record (used by record merger) 514 if "deleted_rb" in table_fields and replaced_by: 515 rb = replaced_by.get(str(record_id)) 516 if rb: 517 data["deleted_rb"] = rb 518 519 try: 520 result = current.db(table._id == record_id).update(**data) 521 except Exception: 522 # Integrity Error 523 self.add_error(record_id, sys.exc_info()[1]) 524 return False 525 526 if not result: 527 # Unknown Error 528 self.add_error(record_id, "archiving failed") 529 return False 530 else: 531 return True
532 533 # -------------------------------------------------------------------------
534 - def delete_record(self, row):
535 """ 536 Delete a record 537 538 @param row: the Row to delete 539 540 @returns: True for success, False on error 541 """ 542 543 table = self.table 544 record_id = row[table._id.name] 545 546 try: 547 result = current.db(table._id == record_id).delete() 548 except Exception: 549 # Integrity Error 550 self.add_error(record_id, sys.exc_info()[1]) 551 return False 552 553 if not result: 554 # Unknown Error 555 self.add_error(record_id, "deletion failed") 556 return False 557 else: 558 return True
559 560 # ------------------------------------------------------------------------- 561 # Properties 562 # ------------------------------------------------------------------------- 563 @property
564 - def super_keys(self):
565 """ 566 List of super-keys (instance links) in this resource 567 568 @returns: a list of field names 569 """ 570 571 super_keys = self._super_keys 572 573 if super_keys is None: 574 575 table_fields = self.table.fields 576 577 super_keys = [] 578 append = super_keys.append 579 580 s3db = current.s3db 581 supertables = s3db.get_config(self.tablename, "super_entity") 582 if supertables: 583 if not isinstance(supertables, (list, tuple)): 584 supertables = [supertables] 585 for sname in supertables: 586 stable = s3db.table(sname) \ 587 if isinstance(sname, str) else sname 588 if stable is None: 589 continue 590 key = stable._id.name 591 if key in table_fields: 592 append(key) 593 594 self._super_keys = super_keys 595 596 return super_keys
597 598 # ------------------------------------------------------------------------- 599 @property
600 - def foreign_keys(self):
601 """ 602 List of foreign key fields in this resource 603 604 @returns: a list of field names 605 """ 606 607 # Produce a list of foreign key Fields in self.table 608 foreign_keys = self._foreign_keys 609 610 if foreign_keys is None: 611 table = self.table 612 foreign_keys = [f for f in table.fields 613 if s3_has_foreign_key(table[f])] 614 self._foreign_keys = foreign_keys 615 616 return foreign_keys
617 618 # ------------------------------------------------------------------------- 619 @property
620 - def references(self):
621 """ 622 A list of foreign keys referencing this resource, 623 lazy property 624 625 @returns: a list of Fields 626 """ 627 628 references = self._references 629 630 if references is None: 631 self.introspect() 632 references = self._references 633 634 return references
635 636 # ------------------------------------------------------------------------- 637 @property
638 - def restrictions(self):
639 """ 640 A list of foreign keys referencing this resource with 641 ondelete="RESTRICT", lazy property 642 643 @returns: a list of Fields 644 """ 645 646 restrictions = self._restrictions 647 648 if restrictions is None: 649 self.introspect() 650 restrictions = self._restrictions 651 652 return restrictions
653 654 # -------------------------------------------------------------------------
655 - def introspect(self):
656 """ 657 Introspect the resource to set process properties 658 """ 659 660 # Must load all models to detect dependencies 661 current.s3db.load_all_models() 662 663 db = current.db 664 if db._lazy_tables: 665 # Must roll out all lazy tables to detect dependencies 666 for tn in db._LAZY_TABLES.keys(): 667 db[tn] 668 669 references = self.table._referenced_by 670 try: 671 restrictions = [f for f in references if f.ondelete == "RESTRICT"] 672 except AttributeError: 673 # Older web2py 674 references = [db[tn][fn] for tn, fn in references] 675 restrictions = [f for f in references if f.ondelete == "RESTRICT"] 676 677 self._references = references 678 self._restrictions = restrictions
679 680 # ------------------------------------------------------------------------- 681 # Error Handling 682 # -------------------------------------------------------------------------
683 - def add_error(self, record_id, msg):
684 """ 685 Add an error 686 687 @param record_id: the record ID 688 @param msg: the error message 689 """ 690 691 key = (self.tablename, record_id) 692 693 error = self.errors.get(key) 694 if type(error) is list: 695 error.append(msg) 696 elif error: 697 self.errors[key] = [error, msg] 698 else: 699 self.errors[key] = msg
700 701 # -------------------------------------------------------------------------
702 - def set_resource_error(self):
703 """ 704 Set the resource.error 705 """ 706 707 if not self.errors: 708 return 709 710 resource = self.resource 711 if self.permission_error: 712 resource.error = current.ERROR.NOT_PERMITTED 713 else: 714 resource.error = current.ERROR.INTEGRITY_ERROR
715 716 # -------------------------------------------------------------------------
717 - def log_errors(self):
718 """ 719 Log all errors of this process instance 720 """ 721 722 if not self.errors: 723 return 724 725 # Log errors 726 for key, errors in self.errors.items(): 727 self._log("Could not delete %s.%s" % key, None, errors)
728 729 # ------------------------------------------------------------------------- 730 @classmethod
731 - def _log(cls, master, reference, errors):
732 """ 733 Log all errors for a failed master record 734 735 @param master: the master log message 736 @param reference: the prefix for the sub-message 737 @param errors: the errors 738 """ 739 740 log = current.log.error 741 742 if isinstance(errors, list): 743 # Multiple errors for the same record 744 for e in errors: 745 cls._log(master, reference, e) 746 747 elif isinstance(errors, dict): 748 # Cascade error (tree of blocking references) 749 if not reference: 750 prefix = "undeletable reference:" 751 else: 752 prefix = "%s <=" % reference 753 for k, e in errors.items(): 754 reference_ = "%s %s.%s" % (prefix, k[0], k[1]) 755 cls._log(master, reference_, e) 756 757 else: 758 # Single error 759 if reference: 760 msg = "%s (%s)" % (reference, errors) 761 else: 762 msg = errors 763 log("%s: %s" % (master, msg))
764 765 # END ========================================================================= 766