From da083ccda4e22473eb9ebb94c4406750fb393ad7 Mon Sep 17 00:00:00 2001 From: Romans Malinovskis Date: Mon, 3 Oct 2016 22:33:39 +0100 Subject: [PATCH 1/9] Implemented insert and delete tracking (also undo) --- src/Controller.php | 74 ++++++++++++++++++++++++---------- src/model/AuditLog.php | 46 +++++++++++++++------ tests/CRUDTest.php | 90 ++++++++++++++++++++++++++---------------- 3 files changed, 142 insertions(+), 68 deletions(-) diff --git a/src/Controller.php b/src/Controller.php index 88d047e..9725fa9 100644 --- a/src/Controller.php +++ b/src/Controller.php @@ -19,11 +19,11 @@ class Controller { public $custom_action = null; public $custom_fields = []; - function __construct($m = null, $options = []) + function __construct($audit_model = null, $options = []) { - $this->audit_model = $m ?: $m = new model\AuditLog(); + $this->audit_model = $audit_model ?: $audit_model = new model\AuditLog(); - foreach($options as $key => $value) { + foreach ($options as $key => $value) { $this->$key = $value; } } @@ -33,7 +33,7 @@ function __construct($m = null, $options = []) */ function setUp(\atk4\data\Model $m) { - $m->addHook('beforeUpdate,afterUpdate', $this); + $m->addHook('beforeSave,afterSave,beforeDelete,afterDelete', $this); $m->addRef('AuditLog', function($m) { $a = clone $this->audit_model; $m->persistence->add($a); @@ -70,12 +70,10 @@ function push(\atk4\data\Model $m, $action) $this->custom_action = null; } - $a['request_diff'] = $this->getDiffs($m); $a['ts'] = new \DateTime(); $a['model'] = get_class($m); $a['model_id'] = $m->id; $a['action'] = $action; - $a['descr'] = $action.' '.$this->getDescr($a['request_diff']); if ($this->custom_fields) { $a->set($this->custom_fields); @@ -96,26 +94,17 @@ function push(\atk4\data\Model $m, $action) $a->start_mt = microtime(); array_unshift($this->audit_log_stack, $a); + return $a; } function pull(\atk4\data\Model $m) { $a = array_shift($this->audit_log_stack); - $a['reactive_diff'] = $this->getDiffs($m); - if($a['reactive_diff'] === $a['request_diff']) { - // Don't store reactive diff if it's identical to requested diff - unset($a['reactive_diff']); - } else { - $x = $a['reactive_diff']; - - $a['descr'].= ' (resulted in '.$this->getDescr($a['reactive_diff']).')'; - } if ($this->record_time_taken) { $a['time_taken'] = microtime() - $a->start_mt; } - - $a->save(); + return $a; } function getDiffs(\atk4\data\Model $m) @@ -127,15 +116,57 @@ function getDiffs(\atk4\data\Model $m) return $diff; } - function beforeUpdate(\atk4\data\Model $m) + function beforeSave(\atk4\data\Model $m) + { + if(!$m->loaded()) { + $a = $this->push($m, $action = 'create'); + } else { + $a = $this->push($m, $action = 'update'); + } + $a['request_diff'] = $this->getDiffs($m); + $a['descr'] = $action.' '.$this->getDescr($a['request_diff']); + } + + function afterSave(\atk4\data\Model $m) { - $this->push($m, 'update'); + $a = $this->pull($m); + + if ($a['model_id'] === null) { + // new record + $a['reactive_diff'] = $m->get(); + $a['model_id'] = $m->id; + } else { + $a['reactive_diff'] = $this->getDiffs($m); + if ($a['reactive_diff'] === $a['request_diff']) { + // Don't store reactive diff if it's identical to requested diff + unset($a['reactive_diff']); + } else { + $x = $a['reactive_diff']; + + $a['descr'].= ' (resulted in '.$this->getDescr($a['reactive_diff']).')'; + } + } + + $a->save(); } + function beforeDelete(\atk4\data\Model $m) + { + $a = $this->push($m, 'delete'); + if ($m->only_fields) { + $id = $m->id; + $m = $m->newInstance()->load($id); // we need all fields + } + $a['request_diff'] = $m->get(); + $a['descr'] = 'delete id='.$m->id; + if ($m->title_field && $m->hasElement($m->title_field)) { + $a['descr'] .= ' ('.$m[$m->title_field].')'; + } + } - function afterUpdate(\atk4\data\Model $m) + function afterDelete(\atk4\data\Model $m) { - $this->pull($m); + $this->pull($m)->save(); } function getDescr($diff) @@ -146,4 +177,5 @@ function getDescr($diff) } return join(', ', $t); } + } diff --git a/src/model/AuditLog.php b/src/model/AuditLog.php index 8e585f7..85489d8 100644 --- a/src/model/AuditLog.php +++ b/src/model/AuditLog.php @@ -12,6 +12,8 @@ class AuditLog extends \atk4\data\Model { public $controller = null; + public $order_field = 'id'; + function init() { parent::init(); @@ -34,6 +36,8 @@ function init() $this->addField('is_reverted', ['type' => 'boolean']); $this->hasOne('revert_audit_log_id', new AuditLog()); + + $this->setOrder($this->order_field.' desc'); } function loadLast() @@ -62,26 +66,44 @@ function undo() $this->atomic(function() { $m = new $this['model']($this->persistence); - $m->load($this['model_id']); - foreach ($this['request_diff'] as $field => list($old, $new)) { - if ($m[$field] !== $new) { - throw new \atk4\core\Exception([ - 'New value does not match current. Risky to undo', - 'new' => $new, 'current' => $m[$field] - ]); - } - - $m[$field] = $old; - } + $f = 'undo_'.$this['action']; $m->audit_log_controller->custom_action = 'undo '.$this['action']; $m->audit_log_controller->custom_fields['revert_audit_log_id'] = $this->id; - $m->save(); + $this->$f($m); $this['is_reverted'] = true; $this->save(); }); } + + function undo_update($m) + { + $m->load($this['model_id']); + + foreach ($this['request_diff'] as $field => list($old, $new)) { + if ($m[$field] !== $new) { + throw new \atk4\core\Exception([ + 'New value does not match current. Risky to undo', + 'new' => $new, 'current' => $m[$field] + ]); + } + + $m[$field] = $old; + } + + $m->save(); + } + function undo_delete($m) + { + $m->set($this['request_diff']); + $m->save(); + } + function undo_create($m) + { + $m->load($this['model_id']); + $m->delete(); + } } diff --git a/tests/CRUDTest.php b/tests/CRUDTest.php index 5bc5386..b68b5bf 100644 --- a/tests/CRUDTest.php +++ b/tests/CRUDTest.php @@ -23,16 +23,8 @@ function init() */ class CRUDTest extends \atk4\schema\PHPUnit_SchemaTestCase { - public function testUpdate() - { - $q = [ - 'user' => [ - ['name' => 'John', 'surname' => 'Smith'], - ['name' => 'Steve', 'surname' => 'Jobs'], - ], - 'audit_log' => [ - '_' => [ + private $audit_db = ['_' => [ 'initiator_audit_log_id' => 1, 'ts' => '', 'model' => '', @@ -45,27 +37,37 @@ public function testUpdate() 'descr' => '', 'is_reverted' => '', 'revert_audit_log_id' => 1 - ] + ]]; + + + public function testUpdate() + { + + $q = [ + 'user' => [ + ['name' => 'Vinny', 'surname' => 'Shira'], + ['name' => 'Zoe', 'surname' => 'Shatwell'], ], + 'audit_log' => $this->audit_db, ]; $this->setDB($q); $m = new AuditableUser($this->db); $m->tryLoadAny(); - $m['name'] = 'QQ'; + $m['name'] = 'Ken'; $m->save(); $l = $m->ref('AuditLog'); $l->loadLast(); - $this->assertEquals('update name=QQ', $l['descr']); - $this->assertEquals(['name' => ['John', 'QQ']], $l['request_diff']); + $this->assertEquals('update name=Ken', $l['descr']); + $this->assertEquals(['name' => ['Vinny', 'Ken']], $l['request_diff']); $m->load(2); - $m['name'] = 'XX'; + $m['name'] = 'Brett'; $m->save(); - $m['name'] = 'YY'; + $m['name'] = 'Doug'; $m->save(); $this->assertEquals(2, $m->ref('AuditLog')->action('count')->getOne()); } @@ -75,32 +77,18 @@ public function testUndo() $q = [ 'user' => [ - ['name' => 'John', 'surname' => 'Smith'], - ['name' => 'Steve', 'surname' => 'Jobs'], - ], - 'audit_log' => [ - '_' => [ - 'initiator_audit_log_id' => 1, - 'ts' => '', - 'model' => '', - 'model_id' => 1, - 'action' => '', - 'user_info' => '', - 'time_taken' => 1.1, - 'request_diff' => '', - 'reactive_diff' => '', - 'descr' => '', - 'is_reverted' => '', - 'revert_audit_log_id' => 1 - ] + ['name' => 'Jawshua', 'surname' => 'Lo'], + ['name' => 'Jessica', 'surname' => 'Fish'], ], + 'audit_log' => $this->audit_db, ]; $this->setDB($q); + $zz = $this->getDB('user'); $m = new AuditableUser($this->db); $m->tryLoadAny(); - $m['name'] = 'QQ'; + $m['name'] = 'Donald'; $m->save(); $l = $m->ref('AuditLog'); @@ -108,12 +96,44 @@ public function testUndo() $m->reload(); - $this->assertEquals('John', $m['name']); + $this->assertEquals('Jawshua', $m['name']); $this->assertEquals(2, $m->ref('AuditLog')->action('count')->getOne()); $l = $m->ref('AuditLog')->loadLast(); $this->assertEquals(1, $l['revert_audit_log_id']); $this->assertEquals(false, $l['is_reverted']); + + // table is back to how it was + $this->assertEquals($zz, $this->getDB('user')); + } + + public function testAddDelete() + { + + $q = [ + 'user' => [ + ['name' => 'Jason', 'surname' => 'Dyck'], + ['name' => 'James', 'surname' => 'Knight'], + ], + 'audit_log' => $this->audit_db, + ]; + $this->setDB($q); + $zz = $this->getDB('user'); + + $m = new AuditableUser($this->db); + + $m->getElement('surname')->default = 'Pen'; + + $m->save(['name'=>'Robert']); + + $m->loadBy('name', 'Jason')->delete(); + + $log = $m->ref('AuditLog'); + + $log->each('undo'); + + // table is back to how it was + $this->assertEquals($zz, $this->getDB('user')); } } From f0c50b71277207ab50bb8d4601f45961b2fd6b40 Mon Sep 17 00:00:00 2001 From: Romans Malinovskis Date: Mon, 3 Oct 2016 22:36:43 +0100 Subject: [PATCH 2/9] cleanup --- src/Controller.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Controller.php b/src/Controller.php index 9725fa9..549d72e 100644 --- a/src/Controller.php +++ b/src/Controller.php @@ -19,9 +19,9 @@ class Controller { public $custom_action = null; public $custom_fields = []; - function __construct($audit_model = null, $options = []) + function __construct($a = null, $options = []) { - $this->audit_model = $audit_model ?: $audit_model = new model\AuditLog(); + $this->audit_model = $a ?: $a = new model\AuditLog(); foreach ($options as $key => $value) { $this->$key = $value; From 2b3ac03f9eb1032d93b60bb3c1cadfb2d67e9218 Mon Sep 17 00:00:00 2001 From: Romans Malinovskis Date: Mon, 3 Oct 2016 23:33:27 +0100 Subject: [PATCH 3/9] added docs --- docs/index.md | 147 +++++++++++++++++++++++------------------ docs/system-wide.md | 37 +++++++++++ src/model/AuditLog.php | 3 + 3 files changed, 122 insertions(+), 65 deletions(-) create mode 100644 docs/system-wide.md diff --git a/docs/index.md b/docs/index.md index a141747..777b427 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,88 +1,105 @@ # Agile Audit Extension -Audit Extension provides a system-wide controller that can be added into any of your or 3rd -party models. Once Added audit will keep track of all record changes. Changes will be stored -into a separate model and will be sufficient to answer the following questions at any later time. +Audit Extension provides a mechanism to store all changes that happen during persistance of your model. This extension is designed to be extensive and flexible. Use Audit if you need to track changes performed by your users in great detail. -## Enable Audit for your Models +Audit supports a wide varietty of additional features such as ability to **undo** actions, record actions that have **failed** to execute (due to validation) along with the error, **retry** failed actions, retrieve **historical** records without modifying database, log **custom** actions and even **replay** all actions. Audit also records which **field values** were changed inside a model before executing `save()` and which fields were changed **reactively** (through other hooks) and will track and link reactive modifications to **multiple models**. -Any model in your system regardless of your database choice can be audited. You can enlist -specific models by adding controller manually or perform a system-wide audit. +Huge focus on extensibility allow you to **customise** name of log table, change field names, database **engine** (e.g. store in CSV file, API or Cloud Database), **switch off** certain features, customise **human-readable** log entries and add additional information about **user**, **session** or **environment**. + +## Enabling Audit Log + +To enable extension for your model, add the following line into Model's method `init`: ``` php -use \atk4\audit; - -class User extends \atk4\data\Model { - public $table = 'user'; - - function init() { - parent::init(); - - $this->addField('name'); - $this->addField('email'); - $this->addField('password'); - - // The following lines enable full audit: - $this->add(new audit\Controller( - new audit\model\AuditLog() - )); - } -} +$this->add(new \atk4\audit\Controller()); +``` + +For a basic usage you will also need to create `audit_log` table by importing `audit_log.sql` file. The audit-log is automatically populated when you perform an operation with the model next time: + +``` php +$m->load(1); +$m['name'] = 'Ken'; // was Vinny before +$m->save(); ``` -You have a choice. You may use `model\AuditLog` that will automatically store information about the enviroment. Alternatively you can supply your own model, which you can extend from `model\AuditLog`. +The following new record will be stored inside `audit_log` table: -## System-wide Audit +``` json +{ + "id":1, + "initiator_audit_log_id":null, + "ts":{ + "date":"2016-10-03 21:44:14.000000", + "timezone_type":3, + "timezone":"UTC" + }, + "model":"atk4\\ui\\tests\\AuditableUser", + "model_id":"1", + "action":"update", + "time_taken":0.00174, + "descr":"update name=Ken", + "user_info":null, + "request_diff":{ + "name":[ + "Vinny", + "Ken" + ] + }, + "reactive_diff":null, + "is_reverted":null, + "revert_audit_log_id":null +} +``` -More often you would want all of your models to be automatically audited. The next snippet demonstrates how to implement numerous things: +Here are some more advanced topics: -- I will be automatically auditing all my models -- If possible, I'll record currently logged user -- I do not want to store certain fields and certain types +- [Enable AuditLog for all your Models](system-wide.md) +- [Configure which fields are logged](field-config.md) +- [Custom event logging and customizing](custom.md) +- [Various storage options](storage.md) -Here is the relevant usage code. I start by defining my own Audit model: +## Working With the Log Entries -```php -class Audit extends \atk4\audit\model\Audit -{ - public $no_audit = true; - - public $exclude_field_values = ['password']; - public $exclude_field_types = ['encrypted']; - - function getExtraData() - { - $extra = []; - - // If we have App context, record currently logged user - if (isset($this->app->user)) { - $extra['user'] = $this->app->user; - } - - return $extra; - } -} +Your model contains reference to AuditLog model. Let's see how many times the above record have been modified in the past: + +``` php +echo $m->load(1)->ref('AuditLog')->action('count')->getOne(); // 1 ``` -To continue I need to add a handler into persistence which will automatically attach itself to all initialized models: +You can also use it to access records individually or just access last record: ``` php -$audit = new \atk4\audit\Controller(new Audit()); - -$db->addHook('afterAdd', function($owner, $element) { - if ($element instanceof \atk4\data\Model) { - if (isset($element->no_audit) && $element->no_audit) { - // Whitelisting this model, won't audit - break; - } - - $element->add($audit); // re-using same object to save resources - } -}); +$m->load(1)->ref('AuditLog')->loadAny()->undo(); // revert last action ``` -You can refer to full class documentation if you want to further extend Audit behaviours. +If you wish to undo all the actions for specific record, run: + +```php +$yesterday = new DateTime(); +$yesterday->sub(new DateInterval('P1D')); + +$m->load(1)->ref('AuditLog') + ->addCondition('date', '>=', $yesterday) + ->addCondition('is_reverted') + ->each('undo'); + // revert all actions, that have happened today + // but exclude those that have been reverted already +``` + + + +More in depth: + +- [How does undo() and redo() work](undo.md) +- [Recording sequences for your unit-tests](unit-tests.md) +- [Fetching historical records](historical.md) + +## Requested and Reactive field changes + +AuditLog extension records fields that were `dirty` before execution of save() operation. Sometimes you would have a logic inside your model hooks that can change more records or even change other models. For example if you change `InvoiceLine` amount it might want to update amount of `Inovice` too. + + ## Requested vs Reactive actions diff --git a/docs/system-wide.md b/docs/system-wide.md new file mode 100644 index 0000000..7bfe233 --- /dev/null +++ b/docs/system-wide.md @@ -0,0 +1,37 @@ +# System-wide Audit Usage + +In order for Audit to work, it requires 2 objects. Controller and AuditLog model. You can extend both of those classes if you wish to redefine any interna behvaiours. + +The default example creates a new controller and inserts it during model init, but normally you would want to re-use single instance of controller. + +```php +$audit_controller = new \atk4\data\audit\Controller(); +``` + +If you wish to link model with this controller manually, you can do so by calling `setUp()`: + +``` php +$invoice = new Invoice($db); +$audit_controller->setUp($invoice); +``` + +Of course setting controllers up like that will take you a lot of effort and add room for error, so instead I recommend you to automatically apply controller for all models through a hook inside `$db`: + +``` php +$audit = new \atk4\audit\Controller(); + +$db->addHook('afterAdd', function($owner, $element) use($audit) { + if ($element instanceof \atk4\data\Model) { + if (isset($element->no_audit) && $element->no_audit) { + // Whitelisting this model, won't audit + break; + } + + $audit->setUp($element); + } +}); +``` + +## Implications of re-using controller + +When one model modifies another model inside a hook, as long as the same controller is used, it is considered as a nested action. For example if you used individual controllers for `InvoiceLine` and `Invoice` models, even if `InvoiceLine` changed `Invoice` reactively those would be stored as independent log entries. If you re-use same audit controller object, then audit-log for `Invoice` will have it's field `initiator_audit_log_id` pointing to the log entry that recorded change in `InvoiceLine`. This can help you to link various actions together. \ No newline at end of file diff --git a/src/model/AuditLog.php b/src/model/AuditLog.php index 85489d8..d2efc2c 100644 --- a/src/model/AuditLog.php +++ b/src/model/AuditLog.php @@ -3,6 +3,9 @@ namespace atk4\audit\model; class AuditLog extends \atk4\data\Model { + + public $no_audit = true; + public $table = 'audit_log'; public $title_field = 'descr'; From ea2e66c0b51b555bcae0cabdaed43d0d71b8a28f Mon Sep 17 00:00:00 2001 From: Romans Malinovskis Date: Tue, 4 Oct 2016 00:14:16 +0100 Subject: [PATCH 4/9] Resolve #5 --- docs/custom.md | 48 +++++++++++++++++ src/Controller.php | 17 +++++- tests/CustomTest.php | 122 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 docs/custom.md create mode 100644 tests/CustomTest.php diff --git a/docs/custom.md b/docs/custom.md new file mode 100644 index 0000000..e46708e --- /dev/null +++ b/docs/custom.md @@ -0,0 +1,48 @@ + +# Custom Events + +There are three ways to add custom events inside audit log. + +## Custom action based on change + +In this approach, we will register a `beforeSave` hook that will examine the nature of our change and if certain condition is met, will customise the log message. + +``` php +$this->addField('gender', ['enum' => ['M','F']]); +$this->addHook('beforeSave', function($m) { + if ($m->isDirty('gender')) { + $m->audit_log['action'] = 'genderbending'; + } +}); +``` + +Property `audit_log` is only available while model is being saved and will point to a model object. After operation completes, the property is removed. + +During the save, however, we can use this to change values. Another important point is that when `audit_log` is initially being set-up it was already saved, so that model will have a real `id` set. If no additional changes are done to the `$m` model or it's `audit_log` model, then there won't be any need to perform secondary save. + +## Setting action before action starts + +The method described above will only work during the modifications hook of the model. What about situations when you want to perform custom action from outside the model? In this case you should set a property for controller: + +``` php +$m->load(2); // surname=Shatwell +$m->audit_log_controller->custom_action = 'married'; +$m['surname'] = 'Shira'; +$m->save(); +``` + +In this example a person is being married, so the surname have to be changed. But instead of using default action, we can set it to `married` through `custom_action` property. + +After the next audit_log operation is completed, the custom_action will be emptied and the next operation will have default action set. + +## Pushing custom actions + +In this final scenario you would want to record action when something happened with the model without actually modifying the model itself. For that AuditLog controller have added a handy method `log()` for you right inside your method: + +``` php +$m->load(2); +$m->log('inspection', ['value' => $m['test_value']]); +$m->unload(); +``` + +In this case the nothing has happened with the model, but we are still recording information about it with a custom action `inspection`. Additionally we are populating `requested_diff` field with second argument array passed into log() method. \ No newline at end of file diff --git a/src/Controller.php b/src/Controller.php index 549d72e..3017fd7 100644 --- a/src/Controller.php +++ b/src/Controller.php @@ -33,7 +33,8 @@ function __construct($a = null, $options = []) */ function setUp(\atk4\data\Model $m) { - $m->addHook('beforeSave,afterSave,beforeDelete,afterDelete', $this); + $m->addHook('beforeSave,beforeDelete', $this, null, -100); + $m->addHook('afterSave,afterDelete', $this, null, 100); $m->addRef('AuditLog', function($m) { $a = clone $this->audit_model; $m->persistence->add($a); @@ -46,6 +47,10 @@ function setUp(\atk4\data\Model $m) return $a; }); + if (!$m->hasMethod('log')) { + $m->addMethod('log', [$this, 'customLog']); + } + $m->audit_log_controller = $this; } @@ -92,6 +97,7 @@ function push(\atk4\data\Model $m, $action) $a->save(); $a->start_mt = microtime(); + $m->audit_log = $a; array_unshift($this->audit_log_stack, $a); return $a; @@ -101,6 +107,8 @@ function pull(\atk4\data\Model $m) { $a = array_shift($this->audit_log_stack); + unset($m->audit_log); + if ($this->record_time_taken) { $a['time_taken'] = microtime() - $a->start_mt; } @@ -116,6 +124,13 @@ function getDiffs(\atk4\data\Model $m) return $diff; } + function customLog(\atk4\data\Model $m, $action, $data = []) + { + $a = $this->push($m, $action); + $a['request_diff'] = $data; + $this->pull($m)->save(); + } + function beforeSave(\atk4\data\Model $m) { if(!$m->loaded()) { diff --git a/tests/CustomTest.php b/tests/CustomTest.php new file mode 100644 index 0000000..817e4ca --- /dev/null +++ b/tests/CustomTest.php @@ -0,0 +1,122 @@ +addField('name'); + $this->addField('surname'); + $this->addField('gender', ['enum' => ['M','F']]); + + $this->add(new \atk4\audit\Controller()); + + $this->addHook('beforeSave', function($m) { + if ($m->isDirty('gender')) { + $m->audit_log['action'] = 'genderbending'; + } + }); + } +} + +/** + * Tests basic create, update and delete operatiotns + */ +class CustomTest extends \atk4\schema\PHPUnit_SchemaTestCase +{ + + private $audit_db = ['_' => [ + 'initiator_audit_log_id' => 1, + 'ts' => '', + 'model' => '', + 'model_id' => 1, + 'action' => '', + 'user_info' => '', + 'time_taken' => 1.1, + 'request_diff' => '', + 'reactive_diff' => '', + 'descr' => '', + 'is_reverted' => '', + 'revert_audit_log_id' => 1 + ]]; + + + public function testBending() + { + + $q = [ + 'user' => [ + ['name' => 'Vinny', 'surname' => 'Shira', 'gender' => 'M'], + ['name' => 'Zoe', 'surname' => 'Shatwell', 'gender' => 'F'], + ], + 'audit_log' => $this->audit_db, + ]; + $this->setDB($q); + + $m = new AuditableGenderUser($this->db); + + $m->tryLoadAny(); + $m['gender'] = 'F'; + $m->save(); + + $l = $m->ref('AuditLog'); + $l->loadLast(); + + $this->assertEquals('genderbending', $l['action']); + } + + public function testLogCustom() + { + + $q = [ + 'user' => [ + ['name' => 'Vinny', 'surname' => 'Shira', 'gender' => 'M'], + ['name' => 'Zoe', 'surname' => 'Shatwell', 'gender' => 'F'], + ], + 'audit_log' => $this->audit_db, + ]; + $this->setDB($q); + + $m = new AuditableGenderUser($this->db); + + $m->load(2); + $m->audit_log_controller->custom_action = 'married'; + $m['surname'] = 'Shira'; + $m->save(); + + $l = $m->ref('AuditLog'); + $l->loadLast(); + + $this->assertEquals('married', $l['action']); + } + + public function testManualLog() + { + + $q = [ + 'user' => [ + ['name' => 'Vinny', 'surname' => 'Shira', 'gender' => 'M'], + ['name' => 'Zoe', 'surname' => 'Shatwell', 'gender' => 'F'], + ], + 'audit_log' => $this->audit_db, + ]; + $this->setDB($q); + + $m = new AuditableGenderUser($this->db); + + $m->load(2); + $m->log('load', ['foo'=>'bar']); + + $l = $m->ref('AuditLog'); + $l->loadLast(); + + $this->assertEquals('load', $l['action']); + $this->assertEquals(['foo'=>'bar'], $l['request_diff']); + } +} From e51381de2a6d939f5edebeb5d04d519f1c02fd05 Mon Sep 17 00:00:00 2001 From: Romans Malinovskis Date: Tue, 4 Oct 2016 00:32:36 +0100 Subject: [PATCH 5/9] fix #4 --- docs/custom.md | 32 +++++++++++++++++++++++++++++++- docs/index.md | 2 +- src/Controller.php | 9 ++++++--- tests/CustomTest.php | 35 ++++++++++++++++++++++++++++++++++- 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/docs/custom.md b/docs/custom.md index e46708e..571b957 100644 --- a/docs/custom.md +++ b/docs/custom.md @@ -45,4 +45,34 @@ $m->log('inspection', ['value' => $m['test_value']]); $m->unload(); ``` -In this case the nothing has happened with the model, but we are still recording information about it with a custom action `inspection`. Additionally we are populating `requested_diff` field with second argument array passed into log() method. \ No newline at end of file +In this case the nothing has happened with the model, but we are still recording information about it with a custom action `inspection`. Additionally we are populating `requested_diff` field with second argument array passed into log() method. + +## Custom AuditLog Model + +Sometimes you would want to have your own model. Although you could create `AuditLog` model from scratch, you would be using some of the useful features, so I recommend you to extend the existing class: + +``` php +class CustomLog extends \atk4\audit\model\AuditLog { + function init(){ + parent::init(); + $this->addField('custom_data', ['type' => 'struct']); + } +} +``` + +This will extend AuditLog by adding additional field definition which you can use for storing your own information in. Type `struct` allow you to store arrays too. + +## Using custom descriptions + +Field `descr` inside a model stores a human-readable value. If you have a different criteria for "human readable", then you can re-define description in your audit model. To do so, define method `getDescr` inside your AuditLog model: + +``` php +class CustomLog extends \atk4\audit\model\AuditLog { + function getDescr(){ + return count($this['request_diff']).' fields magically change'; + } +} +``` + +This will populate `descr` with text like *"2 fields magically change".* + diff --git a/docs/index.md b/docs/index.md index 777b427..14ab46f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -70,7 +70,7 @@ echo $m->load(1)->ref('AuditLog')->action('count')->getOne(); // 1 You can also use it to access records individually or just access last record: ``` php -$m->load(1)->ref('AuditLog')->loadAny()->undo(); // revert last action +$m->load(1)->ref('AuditLog')->loadLast()->undo(); // revert last action ``` If you wish to undo all the actions for specific record, run: diff --git a/src/Controller.php b/src/Controller.php index 3017fd7..5e14327 100644 --- a/src/Controller.php +++ b/src/Controller.php @@ -36,8 +36,10 @@ function setUp(\atk4\data\Model $m) $m->addHook('beforeSave,beforeDelete', $this, null, -100); $m->addHook('afterSave,afterDelete', $this, null, 100); $m->addRef('AuditLog', function($m) { - $a = clone $this->audit_model; - $m->persistence->add($a); + $a = isset($m->audit_model) ? clone $m->audit_model : clone $this->audit_model; + if (!$a->persistence) { + $m->persistence->add($a); + } $a->addCondition('model', get_class($m)); if ($m->loaded()) { @@ -139,7 +141,8 @@ function beforeSave(\atk4\data\Model $m) $a = $this->push($m, $action = 'update'); } $a['request_diff'] = $this->getDiffs($m); - $a['descr'] = $action.' '.$this->getDescr($a['request_diff']); + $a['descr'] = $a->hasMethod('getDescr') ? + $a->getDescr() : $action.' '.$this->getDescr($a['request_diff']); } function afterSave(\atk4\data\Model $m) diff --git a/tests/CustomTest.php b/tests/CustomTest.php index 817e4ca..9c66b18 100644 --- a/tests/CustomTest.php +++ b/tests/CustomTest.php @@ -1,6 +1,6 @@ assertEquals('load', $l['action']); $this->assertEquals(['foo'=>'bar'], $l['request_diff']); } + + public function testCustomDescr() + { + + $q = [ + 'user' => [ + ['name' => 'Vinny', 'surname' => 'Shira', 'gender' => 'M'], + ['name' => 'Zoe', 'surname' => 'Shatwell', 'gender' => 'F'], + ], + 'audit_log' => $this->audit_db, + ]; + $this->setDB($q); + + $m = new AuditableGenderUser($this->db, ['audit_model' => new CustomLog()]); + + $m->load(2); + $m['name'] = 'Joe'; + $m['surname'] = 'XX'; + $m->save(); + + $l = $m->ref('AuditLog'); + $l->loadLast(); + + $this->assertEquals('2 fields magically change', $l['descr']); + } + + } From 83cc1512c7e9d437060db7f1fdffe09d025561d1 Mon Sep 17 00:00:00 2001 From: Romans Malinovskis Date: Tue, 4 Oct 2016 02:48:22 +0100 Subject: [PATCH 6/9] resolve #3, added bunch of tests --- docs/custom.md | 14 +- docs/full-example.md | 357 +++++++++++++++++++++++++++++++++++++++ docs/system-wide.md | 1 - src/Controller.php | 16 +- tests/CustomTest.php | 2 +- tests/MultiModelTest.php | 136 +++++++++++++++ 6 files changed, 518 insertions(+), 8 deletions(-) create mode 100644 docs/full-example.md create mode 100644 tests/MultiModelTest.php diff --git a/docs/custom.md b/docs/custom.md index 571b957..8bf2346 100644 --- a/docs/custom.md +++ b/docs/custom.md @@ -20,6 +20,8 @@ Property `audit_log` is only available while model is being saved and will point During the save, however, we can use this to change values. Another important point is that when `audit_log` is initially being set-up it was already saved, so that model will have a real `id` set. If no additional changes are done to the `$m` model or it's `audit_log` model, then there won't be any need to perform secondary save. +(note: value of `descr` is computed later, but if you set your own value there, then the model will keep it instead) + ## Setting action before action starts The method described above will only work during the modifications hook of the model. What about situations when you want to perform custom action from outside the model? In this case you should set a property for controller: @@ -35,6 +37,15 @@ In this example a person is being married, so the surname have to be changed. Bu After the next audit_log operation is completed, the custom_action will be emptied and the next operation will have default action set. +Additionally you can also set other fields through use of `controller->custom_fields` property: + +```php +$m->load(2); +$m->audit_log_controller->custom_fields['descr'] = 'User got older'; +$m['age']++; +$m->save(); +``` + ## Pushing custom actions In this final scenario you would want to record action when something happened with the model without actually modifying the model itself. For that AuditLog controller have added a handy method `log()` for you right inside your method: @@ -74,5 +85,4 @@ class CustomLog extends \atk4\audit\model\AuditLog { } ``` -This will populate `descr` with text like *"2 fields magically change".* - +This will populate `descr` with text like *"2 fields magically change".* \ No newline at end of file diff --git a/docs/full-example.md b/docs/full-example.md new file mode 100644 index 0000000..10f9d1e --- /dev/null +++ b/docs/full-example.md @@ -0,0 +1,357 @@ +# Example - Invoice Totals + +This is a full demonstration of a basic system designed with Agile Data and Audit extension. The purpose of this system is to store list of invoices, where each invocie could contain multiple lines. + +## Setting up tables + +There are 2 basic tables at play. Invoices: + +| id | ref | total | +| ---- | ---- | ----- | +| 1 | inv1 | 17.70 | + +and invoice contains lines: + +| id | invoice_id | item | price | qty | total | +| ---- | ---------- | ----- | ----- | ---- | ----- | +| 1 | 1 | Chair | 2.50 | 3 | 7.50 | +| 2 | 1 | Desk | 10.20 | 1 | 10.20 | + +The following SQL schema can be used to create above table: + +``` sql +create table invoice (id int not null primary key auto_increment, ref varchar(255), total decimal(8,2)); +create table line (id int not null primary key auto_increment, invoice_id int, item varchar(255), price decimal(8,2), qty int, total decimal(8,2)); +``` + +## Setting up Models + +``` php +class Invoice extends \atk4\data\Model +{ + public $table = 'invoice'; + function init() + { + parent::init(); + + $this->hasMany('Lines', new Line()); + $this->addField('ref', ['type' => 'string']); + $this->addField('total', ['type' => 'money', 'default' => 0.00]); + } + + function adjustTotal($change) + { + if ($this->audit_log_controller) { + $this->audit_log_controller->custom_fields = [ + 'action'=>'total_adjusted', + 'descr'=>'Changing total by '.$change + ]; + } + $this['total'] += $change; + $this->save(); + } +} +``` + +The Invoice model defines all fields and types as well as reference to invoice line model. A new method adjustTotal will be used to increment/decrement invoice total when invoice lines are added or updated. + +Notice how I'm creating a custom `log` entry when adjustTotal() is executed. Next - the Line model: + +``` php +class Line extends \atk4\data\Model { + public $table = 'line'; + + function init() + { + parent::init(); + + $this->hasOne('invoice_id', new Invoice()); + + $this->addField('item', ['type' => 'string']); + $this->addField('price', ['type' => 'money', 'default' => 0.00]); + $this->addField('qty', ['type' => 'integer', 'default' => 0]); + $this->addField('total', ['type' => 'money', 'default' => 0.00]); + + $this->addHook('beforeSave', function($m) { + $m['total'] = $m['price'] * $m['qty']; + }); + + $this->addHook('afterSave', function($m) { + if ($m->isDirty('total')) { + $change = $m['total'] - $m->dirty['total']; + + $this->ref('invoice_id')->adjustTotal($change); + } + }); + } +} +``` + +The model is rather trivial except for the 2 hooks it contains. `beforeSave` model will re-calculate the total based on price and quantity multiplication. After model is saved, if `total` field was affected, then we will calculate the difference and request update from our relevant invoice. + +## Test-code + +``` php +$m = new Invoice($this->db); +$m->save(['ref'=>'inv1']); +$this->assertEquals(0, $m['total']); + +$m->ref('Lines')->insert(['item'=>'Chair', 'price'=>2.50, 'qty'=>3]); +$m->ref('Lines')->insert(['item'=>'Desk', 'price'=>10.20, 'qty'=>1]); + +// output +echo 'invoices = '.json_encode($m->export())."\n"; +echo 'lines = '.json_encode($m->ref('Lines')->export())."\n"; +``` + +Run to get this output (formatted): + +``` json +invoices = [ + { + "id":1, + "ref":"inv1", + "total":17.7 + } +] + +lines = [ + { + "id":1, + "invoice_id":"1", + "item":"Chair", + "price":2.5, + "qty":3, + "total":7.5 + }, + { + "id":2, + "invoice_id":"1", + "item":"Desk", + "price":10.2, + "qty":1, + "total":10.2 + } +] +``` + +## Add AuditLog + +So far everything is working perfectly, but there is no audit yet! To enable audit, we need to execute the following: + +``` php +$audit = new \atk4\audit\Controller(); + +$this->db->addHook('afterAdd', function($owner, $element) use($audit) { + if ($element instanceof \atk4\data\Model) { + if (isset($element->no_audit) && $element->no_audit) { + // Whitelisting this model, won't audit + return; + } + + $audit->setUp($element); + } +}); +``` + +followed by our "test-code" once again. The result is the same, but this time an audit_log table was populated. + +## Explanation of AuditLog Entries + +Let's look inside each audit record individually. + +``` php +$m->save(['ref'=>'inv1']); +``` + +| Field | Value | Description | +| ---------------------- | ------------------------------- | ---------------------------------------- | +| id | 1 | If you use relational database for storing Audit Log, the ID will increment, but that's not a requirement. | +| initiator_audit_log_id | NULL | This action was triggered directly. | +| action | create | New record was created | +| model | Invoice | | +| model_id | 1 | | +| ts | 2016-10-04 00:49:55 | Timestamp always uses UTC as per Agile Data implementation. | +| time_taken | 0.001196 | AuditLog actually tracks how long many seconds this operation took. This can be disabled. | +| request_diff | {"ref":[null,"inv1"]} | SQL stores value in JSON but it's converted into PHP array on load/save. | +| reactive_diff | {"id":1,"ref":"inv1","total":0} | For create operations contains all fields. | +| descr | create ref=inv1 | Human-readable field | +| is_reverted | 0 | Will be set to 1 if you execute `undo()` | +| revert_audit_log_id | null | When reverted, will point to revert log. | + +This record corresponds to us creating initial model. Next we were adding invoice line, which was reflected in the audit_log. + +``` php +$m->ref('Lines')->insert(['item'=>'Chair', 'price'=>2.50, 'qty'=>3]); +``` + +| Field | Value | Description | +| ---------------------- | ---------------------------------------- | ---------------------------------------- | +| id | 2 | | +| initiator_audit_log_id | NULL | Also a manually created record | +| action | create | New line added through insert() | +| model | Line | | +| model_id | 1 | | +| ts | 2016-10-04 00:49:55 | May be same, so this field can't be used for ordering. | +| time_taken | 0.005522 | This action took longer (because of related operation) | +| request_diff | {"item":[null,"Chair"],"price":[0,2.5],"qty":[0,3]} | SQL stores value in JSON but it's converted into PHP array on load/save. | +| reactive_diff | {"id":null,"invoice_id":"1", "item":"Chair","price":2.5, "qty":3,"total":7.5} | All values being stored, including calculated. | +| descr | create item=Chair, price=2.5, qty=3 | Human-readable field | +| is_reverted | 0 | | +| revert_audit_log_id | null | | + +The next entry is reactive and was caused beacuse of the call to `adjustTotal` with a subsequential `save()` + +| Field | Value | Description | +| ---------------------- | --------------------- | ---------------------------------------- | +| id | 3 | | +| initiator_audit_log_id | 2 | Reactive change, caused by previous record. | +| action | total_adjusted | We have manually specified this | +| model | Invoice | | +| model_id | 1 | | +| ts | 2016-10-04 00:49:55 | | +| time_taken | 0.000367 | | +| request_diff | {"total":[0,7.5]} | Total was the only field changed | +| reactive_diff | NULL | When identical to request_diff, this will store NULL to save space | +| descr | Changing total by 7.5 | Human-readable field, specified by us | +| is_reverted | 0 | | +| revert_audit_log_id | null | | + +Next line is similar to the above: + +``` php +$m->ref('Lines')->insert(['item'=>'Desk', 'price'=>10.20, 'qty'=>1]); +``` + +| Field | Value | Description | +| ---------------------- | ---------------------------------------- | ----------- | +| id | 4 | | +| initiator_audit_log_id | NULL | | +| action | create | | +| model | Line | | +| model_id | 1 | | +| ts | 2016-10-04 00:49:55 | | +| time_taken | 0.004329 | | +| request_diff | {"item":[null,"Desk"],"price":[0,10.2],"qty":[0,1]} | | +| reactive_diff | {"id":null,"invoice_id":"1","item":"Desk","price":10.2,"qty":1,"total":10.2} | | +| descr | create item=Desk, price=10.2, qty=1 | | +| is_reverted | 0 | | +| revert_audit_log_id | null | | + +| Field | Value | Description | +| ---------------------- | ---------------------- | ------------------------------------- | +| id | 5 | | +| initiator_audit_log_id | 4 | | +| action | total_adjusted | | +| model | Invoice | | +| model_id | 1 | | +| ts | 2016-10-04 00:49:55 | | +| time_taken | 0.000367 | | +| request_diff | {"total":[7.5,17.7]} | Total was the only field changed | +| reactive_diff | NULL | | +| descr | Changing total by 10.2 | Human-readable field, specified by us | +| is_reverted | 0 | | +| revert_audit_log_id | null | | + +## Now we can Undo things. + +To keep things safe, `undo()` will not work recursively. Let's load our Audit record and call undo() manually on record (4). + +``` php +$a = $this->db->add(clone $audit->audit_model); +$a->load(4)->undo(); +``` + + Looking at the records now, the above operation has removed `line.id=2`, but the total for the Invoice was not updated. The reason is because our model for the Line did not contain `afterDelete()` hook to properly react to deleted records. + +## Remaining Touches + +Attempt to undo action 5 will end in failure: + +``` php +$a->load(4)->undo(); + +// Method is not defined for this object: +// atk4\\audit\\model\\AuditLog +// undo_total_adjusted +``` + +If we wanted to undo this operation, we would have to create our own "undo" handler inside AuditLog explaining how it should be done. We don't really want anything to be done, so we can define a blank method in our code above. + +``` php +// after this line +$audit = new \atk4\audit\Controller(); +// add this line +$audit->audit_model->addMethod('undo_total_adjusted', function() {} ); +``` + +Now if you attempt to undo operation 5, it will successfully mark operation as "un-done". Next lets deal with the problem of totals not being re-calculated on record deletion. To address add the following inside Line::init(): + +``` php +$this->addHook('afterDelete', function($m) { + $this->ref('invoice_id')->adjustTotal(-$m['total']); +}); +``` + +After this modification adding, deleting and "undo" operations will perform correctly. You will also be able to `undo()` log with id=2 which corresponds to addition of first invoice line. As a final modification, let's make sure that invoice deletion would also delete it's lines. Add the following code to Invoice model: + +``` php +$this->addHook('beforeDelete', function($m) { + $m->ref('Lines', ['no_adjust'=>true])->each('delete'); +}); +``` + +The reason I'm passing `no_adjust` here is because I don't want Lines to do unnecessary changes by adjusting total of Invoice that is about to be deleted. We need to listen for this property inside `Line` model: + +``` php +class Line extends \atk4\data\Model { + public $table = 'line'; + + // add this line + public $no_adjust = false; + + function init() + { + parent::init(); + + $this->hasOne('invoice_id', new Invoice()); + + $this->addField('item', ['type' => 'string']); + $this->addField('price', ['type' => 'money', 'default' => 0.00]); + $this->addField('qty', ['type' => 'integer', 'default' => 0]); + $this->addField('total', ['type' => 'money', 'default' => 0.00]); + + // add this line + if ($this->no_adjust) return; + + // rest remains as-is.. + $this->addHook(........ +``` + +## Final run-through + +Executing our test code again: + +``` php +$a = $this->db->add(clone $audit->audit_model); +$a->load(1); +$a->undo(); + +echo 'invoices = '.json_encode($m->export())."\n"; +echo 'lines = '.json_encode($m->ref('Lines')->export())."\n"; +``` + +The `AuditLog.id=1` corresponds to opeartion for adding new Invoice. `undo()` on this operation will delete invoice that will also affect invocie lines. Let's look at the full audit log again: + +| id | initiator | action | model | model_id | revert | revert_id | +| ---- | --------- | -------------- | ------- | -------- | ------ | --------- | +| 1 | | create | Invoice | 1 | 1 | | +| 2 | | create | Line | 1 | | | +| 3 | 2 | total_adjusted | Invoice | 1 | | | +| 4 | | create | Line | 2 | | | +| 5 | 4 | total_adjusted | Invoice | 1 | | | +| 6 | | undo create | Invoice | 1 | | 1 | +| 7 | 6 | delete | Line | 1 | | | +| 8 | 6 | delete | Line | 2 | | | + +I have ommitted details from AuditLog, but the outline above is clean, easy to read and easy to vizualize for the user and very logical. \ No newline at end of file diff --git a/docs/system-wide.md b/docs/system-wide.md index 7bfe233..dac19d4 100644 --- a/docs/system-wide.md +++ b/docs/system-wide.md @@ -26,7 +26,6 @@ $db->addHook('afterAdd', function($owner, $element) use($audit) { // Whitelisting this model, won't audit break; } - $audit->setUp($element); } }); diff --git a/src/Controller.php b/src/Controller.php index 5e14327..9e30394 100644 --- a/src/Controller.php +++ b/src/Controller.php @@ -126,10 +126,15 @@ function getDiffs(\atk4\data\Model $m) return $diff; } - function customLog(\atk4\data\Model $m, $action, $data = []) + function customLog(\atk4\data\Model $m, $action, $descr = null, $fields = []) { $a = $this->push($m, $action); - $a['request_diff'] = $data; + $a['descr'] = $descr ?: $action; + + if ($fields) { + $a->set($fields); + } + $this->pull($m)->save(); } @@ -141,8 +146,11 @@ function beforeSave(\atk4\data\Model $m) $a = $this->push($m, $action = 'update'); } $a['request_diff'] = $this->getDiffs($m); - $a['descr'] = $a->hasMethod('getDescr') ? - $a->getDescr() : $action.' '.$this->getDescr($a['request_diff']); + + if(!$a['descr']) { + $a['descr'] = $a->hasMethod('getDescr') ? + $a->getDescr() : $action.' '.$this->getDescr($a['request_diff']); + } } function afterSave(\atk4\data\Model $m) diff --git a/tests/CustomTest.php b/tests/CustomTest.php index 9c66b18..8545ac4 100644 --- a/tests/CustomTest.php +++ b/tests/CustomTest.php @@ -117,7 +117,7 @@ public function testManualLog() $m = new AuditableGenderUser($this->db); $m->load(2); - $m->log('load', ['foo'=>'bar']); + $m->log('load', 'Testing', ['request_diff' => ['foo'=>'bar']]); $l = $m->ref('AuditLog'); $l->loadLast(); diff --git a/tests/MultiModelTest.php b/tests/MultiModelTest.php new file mode 100644 index 0000000..0db6ded --- /dev/null +++ b/tests/MultiModelTest.php @@ -0,0 +1,136 @@ +hasOne('invoice_id', new Invoice()); + + $this->addField('item', ['type' => 'string']); + $this->addField('price', ['type' => 'money', 'default' => 0.00]); + $this->addField('qty', ['type' => 'integer', 'default' => 0]); + $this->addField('total', ['type' => 'money', 'default' => 0.00]); + + if ($this->no_adjust) return; + + $this->addHook('beforeSave', function($m) { + $m['total'] = $m['price'] * $m['qty']; + }); + + $this->addHook('afterSave', function($m) { + if ($m->isDirty('total')) { + $change = $m['total'] - $m->dirty['total']; + + $this->ref('invoice_id')->adjustTotal($change); + } + }); + + $this->addHook('afterDelete', function($m) { + $this->ref('invoice_id')->adjustTotal(-$m['total']); + }); + } +} + +class Invoice extends \atk4\data\Model +{ + public $table = 'invoice'; + function init() + { + parent::init(); + + $this->hasMany('Lines', new Line()); + $this->addField('ref', ['type' => 'string']); + $this->addField('total', ['type' => 'money', 'default' => 0.00]); + + $this->addHook('beforeDelete', function($m) { + $m->ref('Lines', ['no_adjust'=>true])->each('delete'); + }); + } + + function adjustTotal($change) + { + if ($this->audit_log_controller) { + $this->audit_log_controller->custom_fields = [ + 'action'=>'total_adjusted', + 'descr'=>'Changing total by '.$change + ]; + } + $this['total'] += $change; + $this->save(); + } +} + + +/** + * Tests basic create, update and delete operatiotns + */ +class MultiModelTest extends \atk4\schema\PHPUnit_SchemaTestCase +{ + + private $audit_db = ['_' => [ + 'initiator_audit_log_id' => 1, + 'ts' => '', + 'model' => '', + 'model_id' => 1, + 'action' => '', + 'user_info' => '', + 'time_taken' => 1.1, + 'request_diff' => '', + 'reactive_diff' => '', + 'descr' => '', + 'is_reverted' => '', + 'revert_audit_log_id' => 1 + ]]; + + + public function testTotals() + { + + $q = [ + 'invoice' => ['_'=>['ref'=>'', 'total'=>0.1]], + 'line' => ['_'=>['invoice_id'=>0, 'item'=>'', 'price'=>0.01, 'qty'=>0, 'total'=>0.1]], + 'audit_log' => $this->audit_db, + ]; + $this->setDB($q); + + $audit = new \atk4\audit\Controller(); + $audit->audit_model->addMethod('undo_total_adjusted', function() {} ); + + $this->db->addHook('afterAdd', function($owner, $element) use($audit) { + if ($element instanceof \atk4\data\Model) { + if (isset($element->no_audit) && $element->no_audit) { + // Whitelisting this model, won't audit + return; + } + + $audit->setUp($element); + } + }); + + $m = new Invoice($this->db); + $m->save(['ref'=>'inv1']); + $this->assertEquals(0, $m['total']); + + $m->ref('Lines')->insert(['item'=>'Chair', 'price'=>2.50, 'qty'=>3]); + $m->ref('Lines')->insert(['item'=>'Desk', 'price'=>10.20, 'qty'=>1]); + + //$m->ref('Lines')->ref('AuditLog')->loadLast()->undo(); + + $a = $this->db->add(clone $audit->audit_model); + $a->load(1); + $a->undo(); + + $this->assertEquals(8, count($this->getDB()['audit_log'])); + $this->assertEquals(0, count($this->getDB()['line'])); + $this->assertEquals(0, count($this->getDB()['invoice'])); + } +} From e8c48bc6b51a9c91c657d384a886cefcec4630d1 Mon Sep 17 00:00:00 2001 From: Romans Malinovskis Date: Tue, 4 Oct 2016 02:50:36 +0100 Subject: [PATCH 7/9] update readme --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 44782c6..48e6c8e 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ This extension for Agile Data implements advanced logging capabilities as well as some elements of Event Sourcing. +## Documentation + +https://github.com/atk4/audit/blob/develop/docs/index.md ## Installation From 472a863b9ff135f36f6da7e56c7926e0e2db2cdc Mon Sep 17 00:00:00 2001 From: Romans Malinovskis Date: Tue, 4 Oct 2016 02:54:18 +0100 Subject: [PATCH 8/9] update docs --- README.md | 4 ++++ docs/index.md | 2 ++ 2 files changed, 6 insertions(+) diff --git a/README.md b/README.md index 48e6c8e..79afac7 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ Event Sourcing. https://github.com/atk4/audit/blob/develop/docs/index.md +## Real Usage Example + +https://github.com/atk4/audit/blob/develop/docs/full-example.md + ## Installation Add the following inside your `composer.json` file: diff --git a/docs/index.md b/docs/index.md index 14ab46f..6af3085 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,6 +7,8 @@ Audit supports a wide varietty of additional features such as ability to **undo* Huge focus on extensibility allow you to **customise** name of log table, change field names, database **engine** (e.g. store in CSV file, API or Cloud Database), **switch off** certain features, customise **human-readable** log entries and add additional information about **user**, **session** or **environment**. +(See also - [Full Example](full-example.md)) + ## Enabling Audit Log To enable extension for your model, add the following line into Model's method `init`: From f7a53d37eb07aeceaa6ec7cd803f63e6cf9ae368 Mon Sep 17 00:00:00 2001 From: Romans Malinovskis Date: Tue, 4 Oct 2016 03:03:55 +0100 Subject: [PATCH 9/9] added changelog --- CHANGES.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 CHANGES.md diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..fc41ecd --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,10 @@ +## 1.0 Initial release + + - implement controller and model + - log updates including diffs and all fields + - log create/delete operations + - track multi-model operations + - ability to customize + - add documentation + - undo() operation for update/create/delete + - support for custom actions