# -*- coding: utf-8 -*- import datetime import django from django.db import models as dj_models from django.apps import apps from django.utils.translation import ugettext_lazy as _ from . import models _table_cache = {} #models.AuditTable.objects.all().delete() def extract_object_data(obj): """ Extract data fields from a Model object. """ af_fields = [] # array of Field objects af = getattr(obj, '_audit_fields', None) afe = getattr(obj, '_audit_fields_exclude', None) if af: for f in af: if afe and f in afe: # respect exclude fields continue af_fields.append(obj._meta.get_field(f)) else: if afe: for f in obj._meta.get_fields(): if afe and f.name in afe: # respect exclude fields continue af_fields.append(f) else: af_fields = obj._meta.get_fields() # walk through data fields, extract values data = {} for df in obj._meta.get_fields(): # skip if audit_fields is explicitly defined if af and df.name not in af: continue if df.concrete: if type(df) == dj_models.ForeignKey: data[df.name] = getattr(obj, df.name+"_id") elif type(df) == dj_models.ManyToManyField: data[df.name] = set(getattr(obj, df.name).all().values_list('pk', flat=True)) else: data[df.name] = getattr(obj, df.name) return data class DataAudited: """ Ensure auditing of changes by storing updated fields in a changelog with timestamp, change-transaction-id and the user who made the change. """ class DataNotSavedException(Exception): """ Throw this exception when data was partially saved, but the transaction will be rolled back. Then, we should not save the diff, because it will actually not happen. """ pass def __init__(self): self.__audit_table = None """ Link to the object structure cache. """ self.__orig_data = None """ Is None when not in DataAudited "with" block of code. """ self.__audit_user = None """ The user actually doing the changes in DB. """ self.__save = self.save """ Original save method. """ self.__audit_start_ts = None """ Timestamp of AuditContext creation. """ # setup our save method self.save = self.__audited_save__ def __cache_setup__(self): """ Check and update the object cache. This calculation CAN NOT be done in __init__ because of possible circular dependencies in the models. """ tbl_name = self._meta.db_table if tbl_name in _table_cache: self.__audit_table = _table_cache[tbl_name] else: # create the record _table_cache[tbl_name] = { 'db_table' : tbl_name, 'id' : None, 'fields' : {}, } ltc = _table_cache[tbl_name] # local table cache variable self.__audit_table = ltc # load or create AuditTable rslt = models.AuditTable.objects.filter(name=tbl_name) if len(rslt) == 1: tbl = rslt[0] elif len(rslt) == 0: #print("Creating AuditTable", tbl_name ) tbl = models.AuditTable() tbl.name = tbl_name tbl.save() else: 1/0 ltc['id'] = tbl.id # update AuditFields known_af = tbl.auditfield_set.all() wanted_af = set(extract_object_data(self).keys()) for fld_obj in known_af: if fld_obj.name in wanted_af: # create cache record ltc['fields'][fld_obj.name] = { 'id' : fld_obj.id, 'name' : fld_obj.name, } # remove known fields wanted_af.remove(fld_obj.name) # create remaining wanted_af (there can be some new) for fld_name in wanted_af: #print("Creating AuditField", tbl_name, fld_name ) fld = models.AuditField(table=tbl, name=fld_name) fld.save() # create cache record ltc['fields'][fld_name] = { 'id' : fld.id, 'name' : fld_name, } def __audited_save__(self, *args, **kwargs): """ We audit all save()s, not just the explicit. We save the original save() method and provide our own variant to ensure auditing of save()-s even outside of the 'with' blocks of code. This leaves only direct database updates unaudited. """ if self.__orig_data: # probably already in DataAudited with block, just save self.__save(*args, **kwargs) else: if self.id is None: # just save self.__save(*args, **kwargs) else: # do the audit obj_orig = type(self).objects.get(pk=self.id) self.__audit_start_ts = datetime.datetime.now() self.__orig_data = extract_object_data(obj_orig) self.__save(*args, **kwargs) self.__exit__(None, None, None) self.__orig_data = None self.__audit_start_ts = None def audit_context(self, user=None): """ Setup self as the context. We also record the user doing the changes. Transaction start timestamp is recorded to differentiate nested DataAudited blocks. The transaction number 'tnum' is growing, but later TNUM can be saved earlier then the parent object. Thus, we use timestamp to mitigate for this - correct Audit log ordering is (ts, tnum). """ self.__audit_user = user self.__audit_start_ts = datetime.datetime.now() return self def __enter__(self): #print("__enter__") self.__orig_data = extract_object_data(self) #for k in self.__orig_data: # print(' %s -> %s' % (k, self.__orig_data[k]) ) return self def __exit__(self, exc_type, exc_value, traceback): #print("__exit__", exc_type, exc_value, traceback) if exc_type: if exc_type == self.DataNotSavedException: return True else: return False data_diff = {} data_new = extract_object_data(self) af_private = getattr(self, '_audit_fields_private', None) # We assume no new fields appear after __enter__ and before __exit__, # so we do not look for keys in data_new that are not present in data_orig. for f in self.__orig_data: if self.__orig_data[f] != data_new[f]: if af_private and f in af_private: self.__orig_data[f] = "< ... hidden ... >" data_new[f] = "< ... hidden ... >" data_diff[f] = ( self.__orig_data[f], data_new[f] ) self.__save_diff(data_diff) self.__orig_data = None self.__audit_start_ts = None return True def __save_diff(self, data_diff): """ Save changes recorded in data_diff to DB. """ tnum = 1 if self.__audit_table is None: self.__cache_setup__() print("AuditLog", self.__audit_table['db_table'], 'id', self.id) for k in data_diff: print(' %s: \t %s -> %s' % (k, data_diff[k][0], data_diff[k][1]) ) al = models.AuditLog( ts = self.__audit_start_ts, tnum = tnum, user_id = self.__audit_user.id if self.__audit_user else None, table_id = self.__audit_table['id'], fld_id = self.__audit_table['fields'][k]['id'], rid = self.id, ) al.val = data_diff[k][0] al.save()