1
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"
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
64 tablename = self.tablename = original_tablename(resource.table)
65 table = self.table = current.db[tablename]
66
67
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
77 get_config = resource.get_config
78 self.prepare = get_config("ondelete_cascade")
79 self.ondelete = get_config("ondelete")
80
81
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
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
109 if self._done:
110 raise RuntimeError("deletion already processed")
111 self._done = True
112
113 tablename = self.tablename
114
115
116
117
118 check_all = current.response.s3.debug
119
120
121 rows = self.extract()
122 if not rows:
123
124
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
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
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
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
158 if prepare:
159 try:
160 callback(prepare, record, tablename=tablename)
161 except Exception:
162
163 add_error(record_id, sys.exc_info()[1])
164 continue
165
166 records.append(record)
167
168
169 deletable = self.check_deletable(records, check_all=check_all)
170
171
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
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
196 success = self.cascade(row, check_all=check_all)
197
198 if success:
199
200 success = delete_super(table, row)
201 if not success:
202 add_error(record_id, "super-entity deletion failed")
203
204 if success:
205
206 self.auto_delete_linked(row)
207
208
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
216
217
218 if s3_get_last_record_id(tablename) == record_id:
219 s3_remove_last_record_id(tablename)
220
221
222 audit("delete", prefix, name,
223 record = record_id,
224 representation = self.representation,
225 )
226
227
228 if ondelete:
229 callback(ondelete, row)
230
231
232
233
234
235 if not cascade and skip_undeletable:
236 db.commit()
237
238 num_deleted += 1
239
240 elif not cascade:
241
242 db.rollback()
243 self.log_errors()
244
245 if skip_undeletable:
246
247 continue
248 else:
249
250 break
251 else:
252
253
254 break
255
256 self.set_resource_error()
257 return num_deleted
258
259
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
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
343 deletable.discard(restricted)
344
345
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
393
394
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
413
414
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
479
480
481
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
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
511 data["deleted_fk"] = json.dumps(fk)
512
513
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
523 self.add_error(record_id, sys.exc_info()[1])
524 return False
525
526 if not result:
527
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
550 self.add_error(record_id, sys.exc_info()[1])
551 return False
552
553 if not result:
554
555 self.add_error(record_id, "deletion failed")
556 return False
557 else:
558 return True
559
560
561
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
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
679
680
681
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
715
716
718 """
719 Log all errors of this process instance
720 """
721
722 if not self.errors:
723 return
724
725
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
744 for e in errors:
745 cls._log(master, reference, e)
746
747 elif isinstance(errors, dict):
748
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
759 if reference:
760 msg = "%s (%s)" % (reference, errors)
761 else:
762 msg = errors
763 log("%s: %s" % (master, msg))
764
765
766