| Home | Trees | Indices | Help |
|
|---|
|
|
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
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 # -------------------------------------------------------------------------
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 # -------------------------------------------------------------------------
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 # -------------------------------------------------------------------------
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 # -------------------------------------------------------------------------
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 # -------------------------------------------------------------------------
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 # -------------------------------------------------------------------------
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 # -------------------------------------------------------------------------
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
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
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
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
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 # -------------------------------------------------------------------------
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 # -------------------------------------------------------------------------
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 # -------------------------------------------------------------------------
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 # -------------------------------------------------------------------------
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
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
| Home | Trees | Indices | Help |
|
|---|
| Generated by Epydoc 3.0.1 on Fri Mar 15 08:52:03 2019 | http://epydoc.sourceforge.net |