[object_model, object_id]
159 | */
160 | public function buildObjectParameter(?EntityInterface $object): array
161 | {
162 | $objectModel = null;
163 | $objectId = null;
164 | if ($object instanceof Entity) {
165 | $objectTable = $this->fetchTable($object->getSource());
166 | $objectModel = $objectTable->getRegistryAlias();
167 | $objectId = $this->getScopeId($objectTable, $object);
168 | }
169 |
170 | return [$objectModel, $objectId];
171 | }
172 |
173 | /**
174 | * Get scope's ID
175 | *
176 | * if composite primary key, it will return concatenate values
177 | *
178 | * @param \Cake\ORM\Table $table target table
179 | * @param \Cake\Datasource\EntityInterface $entity an entity
180 | * @return string|int
181 | */
182 | public function getScopeId(Table $table, EntityInterface $entity): string|int
183 | {
184 | $primaryKey = $table->getPrimaryKey();
185 | if (is_string($primaryKey)) {
186 | return $entity->get($primaryKey);
187 | }
188 | // concatenate values, if composite primary key
189 | $ids = [];
190 | foreach ($primaryKey as $field) {
191 | $ids[$field] = $entity->get($field);
192 | }
193 |
194 | return implode('_', array_values($ids));
195 | }
196 | }
197 |
--------------------------------------------------------------------------------
/README.ja.md:
--------------------------------------------------------------------------------
1 | # ActivityLogger plugin for CakePHP 5.x
2 |
3 | ActivityLoggerプラグインは、CakePHPアプリケーションでのデータベース操作(作成・更新・削除)のログを自動的に記録するプラグインです。誰が・いつ・何を変更したかを追跡できます。
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | ## 要件
21 |
22 | - PHP 8.1以上
23 | - CakePHP 5.0以上
24 | - PDO拡張
25 | - JSON拡張
26 |
27 | ## インストール
28 |
29 | [Composer](http://getcomposer.org) を使用してCakePHPアプリケーションにこのプラグインをインストールできます。
30 |
31 | 推奨されるComposerパッケージのインストール方法:
32 |
33 | ```
34 | composer require elstc/cakephp-activity-logger:^3.0
35 | ```
36 |
37 | ### プラグインの読み込み
38 |
39 | プロジェクトの`src/Application.php`に以下の文を追加してプラグインを読み込みます:
40 |
41 | ```php
42 | $this->addPlugin('Elastic/ActivityLogger');
43 | ```
44 |
45 | ### activity_logsテーブルの作成
46 |
47 | マイグレーションコマンドを実行します:
48 |
49 | ```
50 | bin/cake migrations migrate -p Elastic/ActivityLogger
51 | ```
52 |
53 | ## 使用方法
54 |
55 | ### テーブルへのアタッチ
56 |
57 | ActivityLoggerプラグインをテーブルにアタッチして、自動ログ記録を有効にします:
58 |
59 | ```php
60 | class ArticlesTable extends Table
61 | {
62 | public function initialize(array $config): void
63 | {
64 | // ...
65 |
66 | $this->addBehavior('Elastic/ActivityLogger.Logger', [
67 | 'scope' => [
68 | 'Articles',
69 | 'Authors',
70 | ],
71 | ]);
72 | }
73 | }
74 | ```
75 |
76 | ### 基本的なアクティビティログ
77 |
78 | #### 作成時のログ記録
79 | ```php
80 | $article = $this->Articles->newEntity([ /* データ */ ]);
81 | $this->Articles->save($article);
82 | // 保存されるログ
83 | // [action='create', scope_model='Articles', scope_id=$article->id]
84 | ```
85 |
86 | #### 更新時のログ記録
87 | ```php
88 | $article = $this->Articles->patchEntity($article, [ /* 更新データ */ ]);
89 | $this->Articles->save($article);
90 | // 保存されるログ
91 | // [action='update', scope_model='Articles', scope_id=$article->id]
92 | ```
93 |
94 | #### 削除時のログ記録
95 | ```php
96 | $article = $this->Articles->get($id);
97 | $this->Articles->delete($article);
98 | // 保存されるログ
99 | // [action='delete', scope_model='Articles', scope_id=$article->id]
100 | ```
101 |
102 | ### 実行者(Issuer)付きアクティビティログ
103 |
104 | 操作を実行したユーザーの情報をログに記録できます:
105 |
106 | ```php
107 | $this->Articles->setLogIssuer($author); // 実行者を設定
108 |
109 | $article = $this->Articles->newEntity([ /* データ */ ]);
110 | $this->Articles->save($article);
111 |
112 | // 保存されるログ
113 | // [action='create', scope_model='Articles', scope_id=$article->id, ...]
114 | // および
115 | // [action='create', scope_model='Authors', scope_id=$author->id, ...]
116 | ```
117 |
118 | #### AutoIssuerMiddleware(CakePHP 4.x以降推奨)
119 |
120 | `AutoIssuerMiddleware`は、`Authorization`プラグインを使用しているアプリケーションで実行者の自動設定を提供するPSR-15準拠のミドルウェアです。
121 | このミドルウェアはアプリケーションレベルで動作し、リクエストライフサイクルの早い段階で認証情報を処理します。
122 |
123 | ##### インストールと設定
124 |
125 | ```php
126 | // src/Application.php内
127 | use Elastic\ActivityLogger\Http\Middleware\AutoIssuerMiddleware;
128 |
129 | class Application extends BaseApplication
130 | {
131 | public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
132 | {
133 | $middlewareQueue
134 | // ... 他のミドルウェア
135 | ->add(new AuthenticationMiddleware($this))
136 |
137 | // **認証ミドルウェアの後に** AutoIssuerMiddlewareを追加
138 | ->add(new AutoIssuerMiddleware([
139 | 'userModel' => 'Users', // ユーザーモデル名(デフォルト: 'Users')
140 | 'identityAttribute' => 'identity', // リクエスト属性名(デフォルト: 'identity')
141 | ]))
142 |
143 | // ... 他のミドルウェア
144 | ->add(new RoutingMiddleware($this));
145 |
146 | return $middlewareQueue;
147 | }
148 | }
149 | ```
150 |
151 | ##### 重要な注意事項
152 |
153 | - **ミドルウェアの順序**: AutoIssuerMiddlewareは必ず認証ミドルウェアの後に配置してください
154 |
155 | #### AutoIssuerComponent(レガシーアプローチ)
156 |
157 | `Authorization`プラグインや`AuthComponent`を使用している場合、`AutoIssuerComponent`がテーブルに実行者を自動設定してくれます:
158 |
159 | ```php
160 | // AppControllerにて
161 | class AppController extends Controller
162 | {
163 | public function initialize(): void
164 | {
165 | // ...
166 | $this->loadComponent('Elastic/ActivityLogger.AutoIssuer', [
167 | 'userModel' => 'Users', // ユーザーモデル名を指定
168 | ]);
169 | // ...
170 | }
171 | }
172 | ```
173 |
174 | ### スコープ付きアクティビティログ
175 |
176 | 複数のモデルに関連する操作のログを記録できます:
177 |
178 | ```php
179 | class CommentsTable extends Table
180 | {
181 | public function initialize(array $config): void
182 | {
183 | // ...
184 |
185 | $this->addBehavior('Elastic/ActivityLogger.Logger', [
186 | 'scope' => [
187 | 'Articles', // 記事
188 | 'Authors', // 著者
189 | 'Users', // ユーザー
190 | ],
191 | ]);
192 | }
193 | }
194 | ```
195 |
196 | ```php
197 | $this->Comments->setLogScope([$user, $article]); // スコープを設定
198 |
199 | $comment = $this->Comments->newEntity([ /* データ */ ]);
200 | $this->Comments->save($comment);
201 |
202 | // 保存されるログ
203 | // [action='create', scope_model='Users', scope_id=$user->id, ...]
204 | // および
205 | // [action='create', scope_model='Articles', scope_id=$article->id, ...]
206 | ```
207 |
208 | ### メッセージ付きアクティビティログ
209 |
210 | `setLogMessageBuilder`メソッドを使用して、ログのアクションごとにカスタムメッセージを生成できます:
211 |
212 | ```php
213 | class ArticlesTable extends Table
214 | {
215 | public function initialize(array $config): void
216 | {
217 | // ...
218 |
219 | $this->addBehavior('Elastic/ActivityLogger.Logger', [
220 | 'scope' => [
221 | 'Articles',
222 | 'Authors',
223 | ],
224 | ]);
225 |
226 | // メッセージビルダーを追加
227 | $this->setLogMessageBuilder(static function (ActivityLog $log, array $context) {
228 | if ($log->message !== null) {
229 | return $log->message;
230 | }
231 |
232 | $message = '';
233 | $object = $context['object'] ?: null;
234 | $issuer = $context['issuer'] ?: null;
235 | switch ($log->action) {
236 | case ActivityLog::ACTION_CREATE:
237 | $message = sprintf('%3$s が記事 #%1$s: "%2$s" を作成しました', $object->id, $object->title, $issuer->username);
238 | break;
239 | case ActivityLog::ACTION_UPDATE:
240 | $message = sprintf('%3$s が記事 #%1$s: "%2$s" を更新しました', $object->id, $object->title, $issuer->username);
241 | break;
242 | case ActivityLog::ACTION_DELETE:
243 | $message = sprintf('%3$s が記事 #%1$s: "%2$s" を削除しました', $object->id, $object->title, $issuer->username);
244 | break;
245 | default:
246 | break;
247 | }
248 |
249 | return $message;
250 | });
251 | }
252 | }
253 | ```
254 |
255 | または、保存・削除処理の前に`setLogMessage`を使用してログメッセージを設定することもできます:
256 |
257 | ```php
258 | $this->Articles->setLogMessage('カスタムメッセージ');
259 | $this->Articles->save($entity);
260 | // 保存されるログ
261 | // [action='update', 'message' => 'カスタムメッセージ', ...]
262 | ```
263 |
264 | ### カスタムログの保存
265 |
266 | 独自のアクティビティログを記録することも可能です:
267 |
268 | ```php
269 | $this->Articles->activityLog(\Psr\Log\LogLevel::NOTICE, 'カスタムメッセージ', [
270 | 'action' => 'custom',
271 | 'object' => $article,
272 | ]);
273 |
274 | // 保存されるログ
275 | // [action='custom', 'message' => 'カスタムメッセージ', scope_model='Articles', scope_id=$article->id, ...]
276 | ```
277 |
278 | ### アクティビティログの検索
279 |
280 | 記録されたアクティビティログを検索できます:
281 |
282 | ```php
283 | $logs = $this->Articles->find('activity', ['scope' => $article]);
284 | ```
285 |
286 | ## 高度な使用例
287 |
288 | ### 条件付きログ記録
289 |
290 | 特定の条件でのみログを記録したい場合:
291 |
292 | ```php
293 | // 特定のフィールドが変更された場合のみログを記録
294 | if ($article->isDirty('status')) {
295 | $this->Articles->setLogMessage('ステータスが変更されました');
296 | }
297 | $this->Articles->save($article);
298 | ```
299 |
300 | ### バッチ処理でのログ記録
301 |
302 | 大量データの処理時には、ログ記録を一時的に無効化できます:
303 |
304 | ```php
305 | // ログ記録を一時的に無効化
306 | $behavior = $this->Authors->disableActivityLog();
307 |
308 | // バッチ処理
309 | foreach ($articles as $article) {
310 | $this->Articles->save($article);
311 | }
312 |
313 | // ログ記録を再有効化
314 | $this->Articles->enableActivityLog();
315 | ```
316 |
317 | ## トラブルシューティング
318 |
319 | ### よくある問題
320 |
321 | **Q: ログが記録されません**
322 |
323 | A: 以下を確認してください:
324 | - マイグレーションが実行されているか
325 | - Behaviorが正しくアタッチされているか
326 | - データベース接続に問題がないか
327 |
328 | **Q: 実行者の情報が記録されません**
329 |
330 | A: 以下を確認してください:
331 | - AutoIssuerMiddleware使用時:認証ミドルウェアの後に配置されているか確認
332 | - AutoIssuerComponent使用時:コントローラーのinitialize()メソッドで読み込まれているか確認
333 | - 必要に応じて`setLogIssuer()`で手動設定されているか確認
334 | - ユーザーモデルの設定がアプリケーションのユーザーテーブルと一致しているか確認
335 |
336 | **Q: パフォーマンスに影響がありますか?**
337 |
338 | A:
339 | - AutoIssuerMiddlewareはリクエストごとに一度処理されるため、パフォーマンスへの影響は最小限です
340 | - 大量のデータを扱う際は、必要に応じてログ記録を一時的に無効化することを検討してください
341 |
342 | **Q: 動的に読み込まれたテーブルに実行者が設定されません**
343 |
344 | A: AutoIssuerMiddlewareは`Model.initialize`イベントにフックします。以下を確認してください:
345 | - テーブルアクセスの前にミドルウェアが読み込まれている
346 | - テーブルがTableLocatorを通じて読み込まれている(手動でインスタンス化していない)
347 | - LoggerBehaviorがテーブルにアタッチされている
348 |
349 | ## ライセンス
350 |
351 | MITライセンス。詳細は[LICENSE.txt](LICENSE.txt)を参照してください。
352 |
353 | ## 貢献
354 |
355 | バグレポートや機能要求は[GitHub Issues](https://github.com/elstc/cakephp-activity-logger/issues)にお寄せください。
356 |
357 | プルリクエストも歓迎します。大きな変更を行う前に、まずIssueで議論することをお勧めします。
358 |
--------------------------------------------------------------------------------
/tests/TestCase/Controller/Component/AutoIssuerComponentTest.php:
--------------------------------------------------------------------------------
1 |
44 | */
45 | private ComponentRegistry $registry;
46 |
47 | private AuthorsTable $Authors;
48 |
49 | private ArticlesTable $Articles;
50 |
51 | private CommentsTable $Comments;
52 |
53 | private ServerRequest&MockObject $mockRequest;
54 |
55 | /**
56 | * setUp method
57 | *
58 | * @return void
59 | * @noinspection PhpFieldAssignmentTypeMismatchInspection
60 | */
61 | public function setUp(): void
62 | {
63 | parent::setUp();
64 |
65 | // @phpstan-ignore-next-line
66 | $this->Authors = $this->fetchTable('TestApp.Authors', ['className' => AuthorsTable::class]);
67 | // @phpstan-ignore-next-line
68 | $this->Articles = $this->fetchTable('TestApp.Articles', ['className' => ArticlesTable::class]);
69 | // @phpstan-ignore-next-line
70 | $this->Comments = $this->fetchTable('TestApp.Comments', ['className' => CommentsTable::class]);
71 |
72 | $this->mockRequest = $this->createMock(ServerRequest::class);
73 | $this->registry = new ComponentRegistry(new Controller($this->mockRequest));
74 | $this->AutoIssuer = new AutoIssuerComponent($this->registry, [
75 | 'userModel' => 'TestApp.Users',
76 | ]);
77 |
78 | EventManager::instance()->on($this->AutoIssuer);
79 | }
80 |
81 | /**
82 | * tearDown method
83 | *
84 | * @return void
85 | */
86 | public function tearDown(): void
87 | {
88 | unset($this->AutoIssuer, $this->registry, $this->Authors, $this->Articles, $this->Comments);
89 |
90 | parent::tearDown();
91 | }
92 |
93 | /**
94 | * Test initial setup
95 | *
96 | * @return void
97 | */
98 | public function testInitialization(): void
99 | {
100 | // Check default config value
101 | $component = new AutoIssuerComponent($this->registry);
102 |
103 | $this->assertSame([
104 | 'userModel' => 'Users',
105 | 'identityAttribute' => 'identity',
106 | ], $component->getConfig(), 'default config should be set correctly');
107 | }
108 |
109 | /**
110 | * Test Controller.startup Event hook
111 | *
112 | * - Work with Authentication plugin
113 | *
114 | * @return void
115 | * @covers ::startup
116 | * @covers ::getInitializedTables
117 | * @covers ::setIssuerToAllModel
118 | */
119 | public function testStartupWithAuthenticationPlugin(): void
120 | {
121 | // Set identity
122 | $this->mockRequest
123 | ->method('getAttribute')
124 | ->with('identity')
125 | ->willReturn(new User([
126 | 'id' => 1,
127 | ]));
128 |
129 | // Dispatch Controller.startup Event
130 | $event = new Event('Controller.startup');
131 | EventManager::instance()->dispatch($event);
132 |
133 | // An issuer is set to all models that have been called using the TableLocator
134 | $this->assertInstanceOf(User::class, $this->Authors->getLogIssuer());
135 | $this->assertSame(1, $this->Authors->getLogIssuer()->id);
136 | $this->assertInstanceOf(User::class, $this->Articles->getLogIssuer());
137 | $this->assertSame(1, $this->Articles->getLogIssuer()->id);
138 | $this->assertInstanceOf(User::class, $this->Comments->getLogIssuer());
139 | $this->assertSame(1, $this->Comments->getLogIssuer()->id);
140 | }
141 |
142 | /**
143 | * Test Controller.startup Event hook
144 | *
145 | * @return void
146 | */
147 | public function testStartupWithNotAuthenticated(): void
148 | {
149 | // Set identity
150 | $this->mockRequest
151 | ->method('getAttribute')
152 | ->with('identity')
153 | ->willReturn(null);
154 |
155 | // Dispatch Controller.startup Event
156 | $event = new Event('Controller.startup');
157 | EventManager::instance()->dispatch($event);
158 |
159 | // If not authenticated, the issuer will not be set
160 | $this->assertNull($this->Articles->getLogIssuer());
161 | $this->assertNull($this->Comments->getLogIssuer());
162 | $this->assertNull($this->Authors->getLogIssuer());
163 | }
164 |
165 | /**
166 | * Test Controller.startup Event hook
167 | *
168 | * @return void
169 | */
170 | public function testStartupWithOtherIdentity(): void
171 | {
172 | // Set identity
173 | $user = new Author([
174 | 'id' => 1,
175 | ]);
176 | $user->setSource('Authors');
177 | $this->mockRequest
178 | ->method('getAttribute')
179 | ->with('identity')
180 | ->willReturn($user);
181 |
182 | // Dispatch Controller.startup Event
183 | $event = new Event('Controller.startup');
184 | EventManager::instance()->dispatch($event);
185 |
186 | // If not authenticated, the issuer will not be set
187 | $this->assertNull($this->Articles->getLogIssuer());
188 | $this->assertNull($this->Comments->getLogIssuer());
189 | $this->assertNull($this->Authors->getLogIssuer());
190 | }
191 |
192 | /**
193 | * Test Controller.startup Event hook
194 | *
195 | * @return void
196 | */
197 | public function testStartupWithUnknownIdentity(): void
198 | {
199 | // Set identity
200 | $this->mockRequest
201 | ->method('getAttribute')
202 | ->with('identity')
203 | ->willReturn(new User([
204 | 'id' => 0,
205 | ]));
206 |
207 | // Dispatch Controller.startup Event
208 | $event = new Event('Controller.startup');
209 | EventManager::instance()->dispatch($event);
210 |
211 | // If not authenticated, the issuer will not be set
212 | $this->assertNull($this->Articles->getLogIssuer());
213 | $this->assertNull($this->Comments->getLogIssuer());
214 | $this->assertNull($this->Authors->getLogIssuer());
215 | }
216 |
217 | /**
218 | * Test Authentication.afterIdentify Event hook
219 | *
220 | * @return void
221 | */
222 | public function testOnAuthenticationAfterIdentify(): void
223 | {
224 | // Dispatch Authentication.afterIdentify Event
225 | $event = new Event('Authentication.afterIdentify');
226 | $event->setData(['identity' => new ArrayObject(['id' => 2])]);
227 | EventManager::instance()->dispatch($event);
228 |
229 | // An issuer is set to all models that have been called using the TableLocator
230 | $this->assertInstanceOf(User::class, $this->Authors->getLogIssuer());
231 | $this->assertSame(2, $this->Authors->getLogIssuer()->id);
232 | $this->assertInstanceOf(User::class, $this->Articles->getLogIssuer());
233 | $this->assertSame(2, $this->Articles->getLogIssuer()->id);
234 | $this->assertInstanceOf(User::class, $this->Comments->getLogIssuer());
235 | $this->assertSame(2, $this->Comments->getLogIssuer()->id);
236 | }
237 |
238 | /**
239 | * Test Model.initialize Event hook
240 | *
241 | * @return void
242 | */
243 | public function testOnInitializeModel(): void
244 | {
245 | // Set identity
246 | $this->mockRequest
247 | ->method('getAttribute')
248 | ->with('identity')
249 | ->willReturn(new User([
250 | 'id' => 1,
251 | ]));
252 |
253 | // Dispatch Controller.startup Event
254 | $event = new Event('Controller.startup');
255 | EventManager::instance()->dispatch($event);
256 | // --
257 |
258 | // reload Table
259 | $this->getTableLocator()->remove('TestApp.Authors');
260 | /** @noinspection PhpFieldAssignmentTypeMismatchInspection */
261 | // @phpstan-ignore-next-line
262 | $this->Authors = $this->fetchTable('TestApp.Authors', [
263 | 'className' => AuthorsTable::class,
264 | ]);
265 | assert($this->Authors instanceof AuthorsTable);
266 |
267 | // will set issuer
268 | $this->assertInstanceOf(User::class, $this->Authors->getLogIssuer());
269 | $this->assertSame(1, $this->Authors->getLogIssuer()->id);
270 | }
271 |
272 | /**
273 | * Test Model.initialize Event hook when TableLocator is cleared
274 | *
275 | * @return void
276 | */
277 | public function testOnInitializeModelAtClearTableLocator(): void
278 | {
279 | // Set identity
280 | $this->mockRequest
281 | ->method('getAttribute')
282 | ->with('identity')
283 | ->willReturn(new User([
284 | 'id' => 1,
285 | ]));
286 |
287 | // Dispatch Controller.startup Event
288 | $event = new Event('Controller.startup');
289 | EventManager::instance()->dispatch($event);
290 | // --
291 |
292 | // clear TableRegistry
293 | $this->getTableLocator()->clear();
294 | /** @noinspection PhpFieldAssignmentTypeMismatchInspection */
295 | // @phpstan-ignore-next-line
296 | $this->Articles = $this->fetchTable('Articles', [
297 | 'className' => ArticlesTable::class,
298 | ]);
299 | assert($this->Articles instanceof ArticlesTable);
300 |
301 | // will not set issuer (because TableLocator was cleared)
302 | $this->assertNull($this->Articles->getLogIssuer());
303 | }
304 | }
305 |
--------------------------------------------------------------------------------
/tests/TestCase/Http/Middleware/AutoIssuerMiddlewareTest.php:
--------------------------------------------------------------------------------
1 | Authors = $this->fetchTable('TestApp.Authors', ['className' => AuthorsTable::class]);
75 | // @phpstan-ignore-next-line
76 | $this->Articles = $this->fetchTable('TestApp.Articles', ['className' => ArticlesTable::class]);
77 | // @phpstan-ignore-next-line
78 | $this->Comments = $this->fetchTable('TestApp.Comments', ['className' => CommentsTable::class]);
79 |
80 | $this->middleware = new AutoIssuerMiddleware([
81 | 'userModel' => 'TestApp.Users',
82 | ]);
83 |
84 | $this->mockHandler = $this->createMock(RequestHandlerInterface::class);
85 | $this->mockHandler->method('handle')->willReturn(new Response());
86 | }
87 |
88 | /**
89 | * tearDown method
90 | *
91 | * @return void
92 | */
93 | public function tearDown(): void
94 | {
95 | unset($this->middleware, $this->Authors, $this->Articles, $this->Comments, $this->mockHandler);
96 |
97 | parent::tearDown();
98 | }
99 |
100 | /**
101 | * Test initial setup
102 | *
103 | * @return void
104 | */
105 | public function testInitialization(): void
106 | {
107 | // Check default config value
108 | $middleware = new AutoIssuerMiddleware();
109 |
110 | $this->assertSame([
111 | 'userModel' => 'Users',
112 | 'identityAttribute' => 'identity',
113 | ], $middleware->getConfig(), 'default config should be set correctly');
114 | }
115 |
116 | /**
117 | * Test process method with an authenticated user
118 | *
119 | * @return void
120 | * @covers ::process
121 | * @covers ::getInitializedTables
122 | * @covers ::setIssuerToAllModel
123 | */
124 | public function testProcessWithAuthenticatedUser(): void
125 | {
126 | // Create a request with identity
127 | $user = new User([
128 | 'id' => 1,
129 | ]);
130 | $user->setSource('TestApp.Users');
131 |
132 | $request = new ServerRequest();
133 | $request = $request->withAttribute('identity', $user);
134 |
135 | // Process the request
136 | $this->middleware->process($request, $this->mockHandler);
137 |
138 | // An issuer is set to all models that have been called using the TableLocator
139 | $this->assertInstanceOf(User::class, $this->Authors->getLogIssuer());
140 | $this->assertSame(1, $this->Authors->getLogIssuer()->id);
141 | $this->assertInstanceOf(User::class, $this->Articles->getLogIssuer());
142 | $this->assertSame(1, $this->Articles->getLogIssuer()->id);
143 | $this->assertInstanceOf(User::class, $this->Comments->getLogIssuer());
144 | $this->assertSame(1, $this->Comments->getLogIssuer()->id);
145 | }
146 |
147 | /**
148 | * Test process method without identity
149 | *
150 | * @return void
151 | */
152 | public function testProcessWithUnauthenticatedUser(): void
153 | {
154 | // Create a request without authenticated
155 | $request = new ServerRequest();
156 |
157 | // Process the request
158 | $this->middleware->process($request, $this->mockHandler);
159 |
160 | // If not authenticated, the issuer will not be set
161 | $this->assertNull($this->Articles->getLogIssuer());
162 | $this->assertNull($this->Comments->getLogIssuer());
163 | $this->assertNull($this->Authors->getLogIssuer());
164 | }
165 |
166 | /**
167 | * Test process method with array identity data
168 | *
169 | * @return void
170 | */
171 | public function testProcessWithArrayIdentityData(): void
172 | {
173 | // Create a mock identity with the getOriginalData method
174 | $identity = $this->createMock(IdentityInterface::class);
175 | $identity->method('getOriginalData')->willReturn(['id' => 2]);
176 |
177 | $request = new ServerRequest();
178 | $request = $request->withAttribute('identity', $identity);
179 |
180 | // Process the request
181 | $this->middleware->process($request, $this->mockHandler);
182 |
183 | // An issuer is set to all models that have been called using the TableLocator
184 | $this->assertInstanceOf(User::class, $this->Authors->getLogIssuer());
185 | $this->assertSame(2, $this->Authors->getLogIssuer()->id);
186 | $this->assertInstanceOf(User::class, $this->Articles->getLogIssuer());
187 | $this->assertSame(2, $this->Articles->getLogIssuer()->id);
188 | $this->assertInstanceOf(User::class, $this->Comments->getLogIssuer());
189 | $this->assertSame(2, $this->Comments->getLogIssuer()->id);
190 | }
191 |
192 | /**
193 | * Test process method with another entity type as identity
194 | *
195 | * @return void
196 | */
197 | public function testProcessWithOtherIdentity(): void
198 | {
199 | // Create an identity that's not a User entity
200 | $author = new Author([
201 | 'id' => 1,
202 | ]);
203 | $author->setSource('Authors');
204 |
205 | $request = new ServerRequest();
206 | $request = $request->withAttribute('identity', $author);
207 |
208 | // Process the request
209 | $this->middleware->process($request, $this->mockHandler);
210 |
211 | // If not a User entity, the issuer will not be set
212 | $this->assertNull($this->Articles->getLogIssuer());
213 | $this->assertNull($this->Comments->getLogIssuer());
214 | $this->assertNull($this->Authors->getLogIssuer());
215 | }
216 |
217 | /**
218 | * Test process method with unknown user ID
219 | *
220 | * @return void
221 | */
222 | public function testProcessWithUnknownUser(): void
223 | {
224 | // Create a mock identity with non-existent user ID
225 | $identity = $this->createMock(IdentityInterface::class);
226 | $identity->method('getOriginalData')->willReturn(['id' => 999]);
227 |
228 | $request = new ServerRequest();
229 | $request = $request->withAttribute('identity', $identity);
230 |
231 | // Process the request
232 | $this->middleware->process($request, $this->mockHandler);
233 |
234 | // If a user doesn't exist, the issuer will not be set
235 | $this->assertNull($this->Articles->getLogIssuer());
236 | $this->assertNull($this->Comments->getLogIssuer());
237 | $this->assertNull($this->Authors->getLogIssuer());
238 | }
239 |
240 | /**
241 | * Test Model.initialize Event hook
242 | *
243 | * @return void
244 | */
245 | public function testOnInitializeModel(): void
246 | {
247 | // Create a request with identity
248 | $user = new User([
249 | 'id' => 1,
250 | ]);
251 | $user->setSource('TestApp.Users');
252 |
253 | $request = new ServerRequest();
254 | $request = $request->withAttribute('identity', $user);
255 |
256 | // Process the request
257 | $this->middleware->process($request, $this->mockHandler);
258 |
259 | // reload Table
260 | $this->getTableLocator()->remove('TestApp.Authors');
261 | /** @noinspection PhpFieldAssignmentTypeMismatchInspection */
262 | // @phpstan-ignore-next-line
263 | $this->Authors = $this->fetchTable('TestApp.Authors', [
264 | 'className' => AuthorsTable::class,
265 | ]);
266 | assert($this->Authors instanceof AuthorsTable);
267 |
268 | // will set issuer
269 | $this->assertInstanceOf(User::class, $this->Authors->getLogIssuer());
270 | $this->assertSame(1, $this->Authors->getLogIssuer()->id);
271 | }
272 |
273 | /**
274 | * Test Model.initialize Event hook when TableLocator is cleared
275 | *
276 | * @return void
277 | */
278 | public function testOnInitializeModelAtClearTableLocator(): void
279 | {
280 | // Create a request with identity
281 | $user = new User([
282 | 'id' => 1,
283 | ]);
284 | $user->setSource('TestApp.Users');
285 |
286 | $request = new ServerRequest();
287 | $request = $request->withAttribute('identity', $user);
288 |
289 | // Process the request
290 | $this->middleware->process($request, $this->mockHandler);
291 |
292 | // clear TableRegistry
293 | $this->getTableLocator()->clear();
294 | /** @noinspection PhpFieldAssignmentTypeMismatchInspection */
295 | // @phpstan-ignore-next-line
296 | $this->Articles = $this->fetchTable('Articles', [
297 | 'className' => ArticlesTable::class,
298 | ]);
299 | assert($this->Articles instanceof ArticlesTable);
300 |
301 | // will not set issuer (because TableLocator was cleared)
302 | $this->assertNull($this->Articles->getLogIssuer());
303 | }
304 | }
305 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ActivityLogger plugin for CakePHP 5.x
2 |
3 | ActivityLogger plugin automatically logs database operations (create, update, delete) in CakePHP applications. It tracks who, when, and what was changed.
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | ## Requirements
21 |
22 | - PHP 8.1 or higher
23 | - CakePHP 5.0 or higher
24 | - PDO extension
25 | - JSON extension
26 |
27 | ## Installation
28 |
29 | You can install this plugin into your CakePHP application using [composer](http://getcomposer.org).
30 |
31 | The recommended way to install composer packages is:
32 |
33 | ```
34 | composer require elstc/cakephp-activity-logger:^3.0
35 | ```
36 |
37 | ### Load plugin
38 |
39 | Load the plugin by adding the following statement in your project's `src/Application.php`:
40 |
41 | ```php
42 | $this->addPlugin('Elastic/ActivityLogger');
43 | ```
44 |
45 | ### Create the activity_logs table
46 |
47 | Run migration command:
48 |
49 | ```
50 | bin/cake migrations migrate -p Elastic/ActivityLogger
51 | ```
52 |
53 | ## Usage
54 |
55 | ### Attach to Table
56 |
57 | Attach the ActivityLogger plugin to your table to enable automatic logging:
58 |
59 | ```php
60 | class ArticlesTable extends Table
61 | {
62 | public function initialize(array $config): void
63 | {
64 | // ...
65 |
66 | $this->addBehavior('Elastic/ActivityLogger.Logger', [
67 | 'scope' => [
68 | 'Articles',
69 | 'Authors',
70 | ],
71 | ]);
72 | }
73 | }
74 | ```
75 |
76 | ### Basic Activity Logging
77 |
78 | #### Logging on create
79 | ```php
80 | $article = $this->Articles->newEntity([ /* data */ ]);
81 | $this->Articles->save($article);
82 | // saved log
83 | // [action='create', scope_model='Articles', scope_id=$article->id]
84 | ```
85 |
86 | #### Logging on update
87 | ```php
88 | $article = $this->Articles->patchEntity($article, [ /* update data */ ]);
89 | $this->Articles->save($article);
90 | // saved log
91 | // [action='update', scope_model='Articles', scope_id=$article->id]
92 | ```
93 |
94 | #### Logging on delete
95 | ```php
96 | $article = $this->Articles->get($id);
97 | $this->Articles->delete($article);
98 | // saved log
99 | // [action='delete', scope_model='Articles', scope_id=$article->id]
100 | ```
101 |
102 | ### Activity Logging with Issuer
103 |
104 | You can log information about the user who performed the operation:
105 |
106 | ```php
107 | $this->Articles->setLogIssuer($author); // Set issuer
108 |
109 | $article = $this->Articles->newEntity([ /* data */ ]);
110 | $this->Articles->save($article);
111 |
112 | // saved log
113 | // [action='create', scope_model='Articles', scope_id=$article->id, ...]
114 | // and
115 | // [action='create', scope_model='Authors', scope_id=$author->id, ...]
116 | ```
117 |
118 | #### AutoIssuerMiddleware (Recommended for CakePHP 4.x+)
119 |
120 | `AutoIssuerMiddleware` is a PSR-15 compliant middleware that provides automatic issuer setting for applications using the `Authorization` plugin.
121 | This middleware operates at the application level and processes authentication information early in the request lifecycle.
122 |
123 | ##### Installation and Configuration
124 |
125 | ```php
126 | // In src/Application.php
127 | use Elastic\ActivityLogger\Http\Middleware\AutoIssuerMiddleware;
128 |
129 | class Application extends BaseApplication
130 | {
131 | public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
132 | {
133 | $middlewareQueue
134 | // ... other middleware
135 | ->add(new AuthenticationMiddleware($this))
136 |
137 | // Add AutoIssuerMiddleware AFTER authentication middleware
138 | ->add(new AutoIssuerMiddleware([
139 | 'userModel' => 'Users', // User model name (default: 'Users')
140 | 'identityAttribute' => 'identity', // Request attribute name (default: 'identity')
141 | ]))
142 |
143 | // ... other middleware
144 | ->add(new RoutingMiddleware($this));
145 |
146 | return $middlewareQueue;
147 | }
148 | }
149 | ```
150 |
151 | ##### Important Notes
152 |
153 | - **Middleware Order**: Always place AutoIssuerMiddleware AFTER authentication middleware
154 |
155 | #### AutoIssuerComponent (Legacy Approach)
156 |
157 | If you're using `Authorization` plugin or `AuthComponent`, the `AutoIssuerComponent` will automatically set the issuer to Tables:
158 |
159 | ```php
160 | // In AppController
161 | class AppController extends Controller
162 | {
163 | public function initialize(): void
164 | {
165 | // ...
166 | $this->loadComponent('Elastic/ActivityLogger.AutoIssuer', [
167 | 'userModel' => 'Users', // Specify user model name
168 | ]);
169 | // ...
170 | }
171 | }
172 | ```
173 |
174 | ### Activity Logging with Scope
175 |
176 | You can log operations related to multiple models:
177 |
178 | ```php
179 | class CommentsTable extends Table
180 | {
181 | public function initialize(array $config): void
182 | {
183 | // ...
184 |
185 | $this->addBehavior('Elastic/ActivityLogger.Logger', [
186 | 'scope' => [
187 | 'Articles',
188 | 'Authors',
189 | 'Users',
190 | ],
191 | ]);
192 | }
193 | }
194 | ```
195 |
196 | ```php
197 | $this->Comments->setLogScope([$user, $article]); // Set scope
198 |
199 | $comment = $this->Comments->newEntity([ /* data */ ]);
200 | $this->Comments->save($comment);
201 |
202 | // saved log
203 | // [action='create', scope_model='Users', scope_id=$user->id, ...]
204 | // and
205 | // [action='create', scope_model='Articles', scope_id=$article->id, ...]
206 | ```
207 |
208 | ### Activity Logging with Custom Messages
209 |
210 | You can use the `setLogMessageBuilder` method to generate custom messages for each log action:
211 |
212 | ```php
213 | class ArticlesTable extends Table
214 | {
215 | public function initialize(array $config): void
216 | {
217 | // ...
218 |
219 | $this->addBehavior('Elastic/ActivityLogger.Logger', [
220 | 'scope' => [
221 | 'Articles',
222 | 'Authors',
223 | ],
224 | ]);
225 |
226 | // Add message builder
227 | $this->setLogMessageBuilder(static function (ActivityLog $log, array $context) {
228 | if ($log->message !== null) {
229 | return $log->message;
230 | }
231 |
232 | $message = '';
233 | $object = $context['object'] ?: null;
234 | $issuer = $context['issuer'] ?: null;
235 | switch ($log->action) {
236 | case ActivityLog::ACTION_CREATE:
237 | $message = sprintf('%3$s created article #%1$s: "%2$s"', $object->id, $object->title, $issuer->username);
238 | break;
239 | case ActivityLog::ACTION_UPDATE:
240 | $message = sprintf('%3$s updated article #%1$s: "%2$s"', $object->id, $object->title, $issuer->username);
241 | break;
242 | case ActivityLog::ACTION_DELETE:
243 | $message = sprintf('%3$s deleted article #%1$s: "%2$s"', $object->id, $object->title, $issuer->username);
244 | break;
245 | default:
246 | break;
247 | }
248 |
249 | return $message;
250 | });
251 | }
252 | }
253 | ```
254 |
255 | Alternatively, you can use `setLogMessage` before save/delete operations to set a log message:
256 |
257 | ```php
258 | $this->Articles->setLogMessage('Custom Message');
259 | $this->Articles->save($entity);
260 | // saved log
261 | // [action='update', 'message' => 'Custom Message', ...]
262 | ```
263 |
264 | ### Save Custom Log
265 |
266 | You can also record your own activity logs:
267 |
268 | ```php
269 | $this->Articles->activityLog(\Psr\Log\LogLevel::NOTICE, 'Custom Message', [
270 | 'action' => 'custom',
271 | 'object' => $article,
272 | ]);
273 |
274 | // saved log
275 | // [action='custom', 'message' => 'Custom Message', scope_model='Articles', scope_id=$article->id, ...]
276 | ```
277 |
278 | ### Find Activity Logs
279 |
280 | You can search recorded activity logs:
281 |
282 | ```php
283 | $logs = $this->Articles->find('activity', ['scope' => $article]);
284 | ```
285 |
286 | ## Advanced Usage Examples
287 |
288 | ### Conditional Logging
289 |
290 | When you want to log only under certain conditions:
291 |
292 | ```php
293 | // Log only when specific fields are changed
294 | if ($article->isDirty('status')) {
295 | $this->Articles->setLogMessage('Status was changed');
296 | }
297 | $this->Articles->save($article);
298 | ```
299 |
300 | ### Batch Processing with Logging
301 |
302 | During large data processing, you can temporarily disable logging:
303 |
304 | ```php
305 | // Temporarily disable logging
306 | $behavior = $this->Authors->disableActivityLog();
307 |
308 | // Batch processing
309 | foreach ($articles as $article) {
310 | $this->Articles->save($article);
311 | }
312 |
313 | // Re-enable logging
314 | $this->Articles->enableActivityLog();
315 | ```
316 |
317 | ## Troubleshooting
318 |
319 | ### Common Issues
320 |
321 | **Q: Logs are not being recorded**
322 |
323 | A: Please check the following:
324 | - Whether migrations have been executed
325 | - Whether the Behavior is properly attached
326 | - Whether there are any database connection issues
327 |
328 | **Q: Issuer information is not being recorded**
329 |
330 | A: Please check the following:
331 | - If using AutoIssuerMiddleware: Ensure it's placed AFTER authentication middleware in the middleware queue
332 | - If using AutoIssuerComponent: Verify it's loaded in your controller's initialize() method
333 | - Check if `setLogIssuer()` is manually set when needed
334 | - Verify the user model configuration matches your application's user table
335 |
336 | **Q: AutoIssuerMiddleware vs AutoIssuerComponent - Which should I use?**
337 |
338 | A:
339 | - **Use AutoIssuerMiddleware** (Recommended for CakePHP 4.x+):
340 | - For new applications
341 | - When you need application-wide issuer tracking
342 | - For better performance and cleaner architecture
343 | - When using PSR-15 middleware stack
344 |
345 | - **Use AutoIssuerComponent**:
346 | - For legacy applications or CakePHP 3.x
347 | - When you need controller-specific issuer handling
348 | - For backward compatibility
349 |
350 | **Q: Is there any performance impact?**
351 |
352 | A:
353 | - AutoIssuerMiddleware has minimal performance impact as it processes once per request
354 | - When handling large amounts of data, consider temporarily disabling logging as needed
355 |
356 | **Q: The issuer is not set for dynamically loaded Tables**
357 |
358 | A: The AutoIssuerMiddleware hooks into `Model.initialize` events. Ensure:
359 | - The middleware is loaded before any Table access
360 | - Tables are loaded through the TableLocator (not manually instantiated)
361 | - The LoggerBehavior is attached to the Table
362 |
363 | ## License
364 |
365 | MIT License. See [LICENSE.txt](LICENSE.txt) for details.
366 |
367 | ## Contributing
368 |
369 | Bug reports and feature requests are welcome at [GitHub Issues](https://github.com/elstc/cakephp-activity-logger/issues).
370 |
371 | Pull requests are also welcome. We recommend discussing large changes in an Issue first.
372 |
--------------------------------------------------------------------------------
/src/Model/Behavior/LoggerBehavior.php:
--------------------------------------------------------------------------------
1 |
24 | * public function initialize(array $config)
25 | * {
26 | * $this->addBehavior('Elastic/ActivityLogger.Logger', [
27 | * 'scope' => [
28 | * 'Authors',
29 | * 'Articles',
30 | * 'PluginName.Users',
31 | * ],
32 | * ]);
33 | * }
34 | *
35 | *
36 | * set Scope/Issuer
37 | *
38 | * $commentsTable->logScope([$article, $author])->logIssuer($user);
39 | *
40 | */
41 | class LoggerBehavior extends Behavior
42 | {
43 | use LocatorAwareTrait;
44 |
45 | /**
46 | * Default configuration.
47 | *
48 | * @var array
49 | */
50 | protected array $_defaultConfig = [
51 | 'logModel' => 'Elastic/ActivityLogger.ActivityLogs',
52 | 'logModelAlias' => 'ActivityLogs',
53 | 'scope' => [],
54 | 'systemScope' => true,
55 | 'scopeMap' => [],
56 | 'implementedMethods' => [
57 | 'activityLog' => 'activityLog',
58 | 'getLogIssuer' => 'getLogIssuer',
59 | 'getLogMessageBuilder' => 'getLogMessageBuilder',
60 | 'getLogScope' => 'getLogScope',
61 | 'setLogIssuer' => 'setLogIssuer',
62 | 'setLogMessageBuilder' => 'setLogMessageBuilder',
63 | 'setLogMessage' => 'setLogMessage',
64 | 'setLogScope' => 'setLogScope',
65 | 'resetLogScope' => 'resetLogScope',
66 | 'disableActivityLog' => 'disableActivityLog',
67 | 'enableActivityLog' => 'enableActivityLog',
68 | ],
69 | ];
70 |
71 | /**
72 | * @return array
73 | */
74 | public function implementedEvents(): array
75 | {
76 | return parent::implementedEvents() + [
77 | 'Model.initialize' => 'afterInit',
78 | ];
79 | }
80 |
81 | /**
82 | * Disable activity log
83 | *
84 | * @return void
85 | */
86 | public function disableActivityLog(): void
87 | {
88 | $this->_table->getEventManager()->off($this);
89 | }
90 |
91 | /**
92 | * Enable activity log
93 | *
94 | * @return void
95 | */
96 | public function enableActivityLog(): void
97 | {
98 | $this->_table->getEventManager()->on($this);
99 | }
100 |
101 | /**
102 | * Run at after Table.initialize event
103 | *
104 | * @return void
105 | * @noinspection PhpUnused
106 | */
107 | public function afterInit(): void
108 | {
109 | $scope = $this->getConfig('scope');
110 |
111 | if (empty($scope)) {
112 | $scope = [$this->_table->getRegistryAlias()];
113 | }
114 |
115 | if ($this->getConfig('systemScope')) {
116 | $namespace = $this->getConfig('systemScope') === true
117 | ? Configure::read('App.namespace')
118 | : $this->getConfig('systemScope');
119 | $scope['\\' . $namespace] = true;
120 | }
121 |
122 | $this->setConfig('scope', $scope, false);
123 | $this->setConfig('originalScope', $scope);
124 | }
125 |
126 | /**
127 | * @param \Cake\Event\Event<\Cake\ORM\Table> $event the event
128 | * @param \Cake\Datasource\EntityInterface $entity saving entity
129 | * @return void
130 | * @noinspection PhpUnusedParameterInspection
131 | */
132 | public function afterSave(Event $event, EntityInterface $entity): void
133 | {
134 | $entity->setSource($this->_table->getRegistryAlias()); // for entity of belongsToMany intermediate table
135 |
136 | /** @var \Elastic\ActivityLogger\Model\Entity\ActivityLog $log */
137 | $log = $this->buildLog($entity, $this->getConfig('issuer'));
138 | $log->action = $entity->isNew() ? ActivityLog::ACTION_CREATE : ActivityLog::ACTION_UPDATE;
139 | $log->data = $this->getDirtyData($entity);
140 | $log->message = $this->buildMessage($log, $entity, $this->getConfig('issuer'));
141 |
142 | $logs = $this->duplicateLogByScope($this->getConfig('scope'), $log, $entity);
143 | $this->saveLogs($logs);
144 | }
145 |
146 | /**
147 | * @param \Cake\Event\Event<\Cake\ORM\Table> $event the event
148 | * @param \Cake\Datasource\EntityInterface $entity deleted entity
149 | * @return void
150 | * @noinspection PhpUnusedParameterInspection
151 | */
152 | public function afterDelete(Event $event, EntityInterface $entity): void
153 | {
154 | $entity->setSource($this->_table->getRegistryAlias()); // for entity of belongsToMany intermediate table
155 |
156 | /** @var \Elastic\ActivityLogger\Model\Entity\ActivityLog $log */
157 | $log = $this->buildLog($entity, $this->getConfig('issuer'));
158 | $log->action = ActivityLog::ACTION_DELETE;
159 | $log->data = $this->getData($entity);
160 | $log->message = $this->buildMessage($log, $entity, $this->getConfig('issuer'));
161 |
162 | $logs = $this->duplicateLogByScope($this->getConfig('scope'), $log, $entity);
163 |
164 | $this->saveLogs($logs);
165 | }
166 |
167 | /**
168 | * Get the log scope
169 | *
170 | * @return array
171 | */
172 | public function getLogScope(): array
173 | {
174 | return $this->getConfig('scope');
175 | }
176 |
177 | /**
178 | * Set the log scope
179 | *
180 | * @param \Cake\Datasource\EntityInterface|array|array<\Cake\Datasource\EntityInterface>|string $args the log scope
181 | * @return \Cake\ORM\Table|self
182 | */
183 | public function setLogScope(string|array|EntityInterface $args): Table|self
184 | {
185 | // setter
186 | if (!is_array($args)) {
187 | $args = [$args];
188 | }
189 |
190 | $scope = [];
191 | foreach ($args as $key => $val) {
192 | if (is_int($key) && is_string($val)) {
193 | // [0 => 'Scope']
194 | $scope[$val] = true;
195 | } else {
196 | $scope[$key] = $val;
197 | }
198 | }
199 | $this->setConfig('scope', $scope);
200 |
201 | return $this->_table;
202 | }
203 |
204 | /**
205 | * Get the log issuer
206 | *
207 | * @return \Cake\Datasource\EntityInterface|null
208 | */
209 | public function getLogIssuer(): ?EntityInterface
210 | {
211 | return $this->getConfig('issuer');
212 | }
213 |
214 | /**
215 | * Set the log issuer
216 | *
217 | * @param \Cake\Datasource\EntityInterface $issuer the issuer
218 | * @return \Cake\ORM\Table|self
219 | */
220 | public function setLogIssuer(EntityInterface $issuer): Table|self
221 | {
222 | $this->setConfig('issuer', $issuer);
223 |
224 | // set issuer to scope if the scopes contain the issuer's model
225 | [$issuerModel] = $this->buildObjectParameter($this->getConfig('issuer'));
226 | if (array_key_exists((string)$issuerModel, $this->getConfig('scope'))) {
227 | $this->setLogScope($issuer);
228 | }
229 |
230 | return $this->_table;
231 | }
232 |
233 | /**
234 | * Get the log message builder
235 | *
236 | * @return callable|null
237 | */
238 | public function getLogMessageBuilder(): ?callable
239 | {
240 | return $this->getConfig('messageBuilder');
241 | }
242 |
243 | /**
244 | * Set the log message builder
245 | *
246 | * @param callable|null $handler the message build method
247 | * @return \Cake\ORM\Table|self
248 | */
249 | public function setLogMessageBuilder(?callable $handler = null): Table|self
250 | {
251 | $this->setConfig('messageBuilder', $handler);
252 |
253 | return $this->_table;
254 | }
255 |
256 | /**
257 | * Set a log message
258 | *
259 | * @param string $message the message
260 | * @param bool $persist if true, keeps the message.
261 | * @return \Cake\ORM\Table|self
262 | */
263 | public function setLogMessage(string $message, bool $persist = false): Table|self
264 | {
265 | $this->setLogMessageBuilder(function () use ($message, $persist) {
266 | if (!$persist) {
267 | $this->setLogMessageBuilder(null);
268 | }
269 |
270 | return $message;
271 | });
272 |
273 | return $this->_table;
274 | }
275 |
276 | /**
277 | * Record a custom log
278 | *
279 | * @param string $level log level
280 | * @param string $message log message
281 | * @param array $context context data
282 | * [
283 | * 'object' => Entity,
284 | * 'issuer' => Entity,
285 | * 'scope' => Entity[],
286 | * 'action' => string,
287 | * 'data' => array,
288 | * ]
289 | * @return array<\Elastic\ActivityLogger\Model\Entity\ActivityLog>
290 | */
291 | public function activityLog(string $level, string $message, array $context = []): array
292 | {
293 | $entity = $context['object'] ?? null;
294 | $issuer = $context['issuer'] ?? $this->getConfig('issuer');
295 | $scope = !empty($context['scope'])
296 | ? $this->buildScope($context['scope'])
297 | : $this->getConfig('scope');
298 |
299 | /** @var \Elastic\ActivityLogger\Model\Entity\ActivityLog $log */
300 | $log = $this->buildLog($entity, $issuer);
301 | $patchMethod = method_exists($log, 'patch') ? 'patch' : 'set';
302 | $log->$patchMethod([
303 | 'action' => $context['action'] ?? ActivityLog::ACTION_RUNTIME,
304 | 'data' => $context['data'] ?? $this->getData($entity),
305 | 'level' => $level,
306 | 'message' => $message,
307 | ]);
308 | $log->message = $this->buildMessage($log, $entity, $issuer);
309 |
310 | // set issuer to scope if the scopes contain the issuer's model
311 | if (
312 | !empty($log->issuer_id) &&
313 | !empty($log->issuer_model) &&
314 | array_key_exists($log->issuer_model, $this->getConfig('scope'))
315 | ) {
316 | $scope[$log->issuer_model] = $log->issuer_id;
317 | }
318 |
319 | $logs = $this->duplicateLogByScope($scope, $log, $entity);
320 |
321 | $this->saveLogs($logs);
322 |
323 | return $logs;
324 | }
325 |
326 | /**
327 | * Activity log finder
328 | *
329 | * $table->find('activity', scope: $entity)
330 | *
331 | * @param \Cake\ORM\Query\SelectQuery<\Elastic\ActivityLogger\Model\Entity\ActivityLog> $query the query
332 | * @param \Cake\Datasource\EntityInterface|null $scope the scope entity
333 | * @return \Cake\ORM\Query\SelectQuery<\Elastic\ActivityLogger\Model\Entity\ActivityLog>
334 | * @noinspection PhpUnusedParameterInspection
335 | */
336 | public function findActivity(SelectQuery $query, ?EntityInterface $scope = null): SelectQuery
337 | {
338 | $logTable = $this->getLogTable();
339 | $logQuery = $logTable->find();
340 |
341 | $where = [$logTable->aliasField('scope_model') => $this->_table->getRegistryAlias()];
342 |
343 | if ($scope) {
344 | [$scopeModel, $scopeId] = $this->buildObjectParameter($scope);
345 | $where[$logTable->aliasField('scope_model')] = $scopeModel;
346 | $where[$logTable->aliasField('scope_id')] = $scopeId;
347 | }
348 |
349 | $logQuery->where($where)->orderBy([$logTable->aliasField('id') => 'desc']);
350 |
351 | return $logQuery;
352 | }
353 |
354 | /**
355 | * Build log entity
356 | *
357 | * @param \Cake\Datasource\EntityInterface|null $entity the entity
358 | * @param \Cake\Datasource\EntityInterface|null $issuer the issuer
359 | * @return \Elastic\ActivityLogger\Model\Entity\ActivityLog|\Cake\Datasource\EntityInterface
360 | */
361 | private function buildLog(
362 | ?EntityInterface $entity = null,
363 | ?EntityInterface $issuer = null,
364 | ): ActivityLog|EntityInterface {
365 | [$issuer_model, $issuer_id] = $this->buildObjectParameter($issuer);
366 | [$object_model, $object_id] = $this->buildObjectParameter($entity);
367 |
368 | $level = LogLevel::INFO;
369 | $message = '';
370 |
371 | return $this->getLogTable()
372 | ->newEntity(compact(
373 | 'issuer_model',
374 | 'issuer_id',
375 | 'object_model',
376 | 'object_id',
377 | 'level',
378 | 'message',
379 | ));
380 | }
381 |
382 | /**
383 | * Build parameter from an entity
384 | *
385 | * @param \Cake\Datasource\EntityInterface|null $object the object
386 | * @return array [object_model, object_id]
387 | * @see \Elastic\ActivityLogger\Model\Table\ActivityLogsTable::buildObjectParameter()
388 | */
389 | private function buildObjectParameter(?EntityInterface $object): array
390 | {
391 | return $this->getLogTable()->buildObjectParameter($object);
392 | }
393 |
394 | /**
395 | * Build a log message
396 | *
397 | * @param \Elastic\ActivityLogger\Model\Entity\ActivityLog|\Cake\Datasource\EntityInterface $log log object
398 | * @param \Cake\Datasource\EntityInterface|null $entity saved entity
399 | * @param \Cake\Datasource\EntityInterface|null $issuer issuer
400 | * @return string
401 | */
402 | private function buildMessage(
403 | ActivityLog|EntityInterface $log,
404 | ?EntityInterface $entity = null,
405 | ?EntityInterface $issuer = null,
406 | ): string {
407 | if (!is_callable($this->getConfig('messageBuilder'))) {
408 | return $log->get('message') ?: '';
409 | }
410 |
411 | $context = ['object' => $entity, 'issuer' => $issuer];
412 |
413 | return call_user_func($this->getConfig('messageBuilder'), $log, $context);
414 | }
415 |
416 | /**
417 | * Duplicate the log by scopes
418 | *
419 | * @param array $scope target scope
420 | * @param \Elastic\ActivityLogger\Model\Entity\ActivityLog $log duplicate logs
421 | * @param \Cake\Datasource\EntityInterface|null $entity the entity
422 | * @return array
423 | */
424 | private function duplicateLogByScope(array $scope, ActivityLog $log, ?EntityInterface $entity = null): array
425 | {
426 | $logs = [];
427 |
428 | if ($entity !== null) {
429 | // Auto mapping from fields
430 | foreach ($this->getConfig('scopeMap') as $field => $scopeModel) {
431 | if (array_key_exists($scopeModel, $scope) && !empty($entity->get($field))) {
432 | $scope[$scopeModel] = $entity->get($field);
433 | }
434 | }
435 | }
436 |
437 | foreach ($scope as $scopeModel => $scopeId) {
438 | if ($entity !== null && $scopeModel === $this->_table->getRegistryAlias()) {
439 | // Set the entity id to scope, if own scope
440 | $scopeId = $this->getLogTable()->getScopeId($this->_table, $entity);
441 | }
442 | if (empty($scopeId)) {
443 | continue;
444 | }
445 |
446 | /** @var \Elastic\ActivityLogger\Model\Entity\ActivityLog $new */
447 | $new = $this->getLogTable()->newEntity($log->toArray() + [
448 | 'scope_model' => $scopeModel,
449 | 'scope_id' => $scopeId,
450 | ]);
451 |
452 | $logs[] = $new;
453 | }
454 |
455 | return $logs;
456 | }
457 |
458 | /**
459 | * @param iterable<\Elastic\ActivityLogger\Model\Entity\ActivityLog> $logs save logs
460 | * @return void
461 | */
462 | private function saveLogs(iterable $logs): void
463 | {
464 | /** @var \Elastic\ActivityLogger\Model\Table\ActivityLogsTable $logTable */
465 | $logTable = $this->getLogTable();
466 | foreach ($logs as $log) {
467 | $logTable->save($log, ['atomic' => false]);
468 | }
469 | }
470 |
471 | /**
472 | * @return \Elastic\ActivityLogger\Model\Table\ActivityLogsTableInterface&\Cake\ORM\Table
473 | */
474 | private function getLogTable(): ActivityLogsTableInterface&Table
475 | {
476 | /** @var \Elastic\ActivityLogger\Model\Table\ActivityLogsTableInterface&\Cake\ORM\Table $table */
477 | $table = $this->fetchTable($this->getConfig('logModelAlias'), [
478 | 'className' => $this->getConfig('logModel'),
479 | ]);
480 |
481 | assert(
482 | $table instanceof ActivityLogsTableInterface && $table instanceof Table,
483 | 'LogModel must implement ActivityLogsTableInterface',
484 | );
485 |
486 | return $table;
487 | }
488 |
489 | /**
490 | * Get modified values from the entity
491 | *
492 | * - exclude hidden values
493 | *
494 | * @param \Cake\Datasource\EntityInterface|null $entity the entity
495 | * @return array
496 | */
497 | private function getDirtyData(?EntityInterface $entity = null): array
498 | {
499 | if ($entity === null) {
500 | return [];
501 | }
502 |
503 | return $entity->extract($entity->getVisible(), true);
504 | }
505 |
506 | /**
507 | * Get values from the entity
508 | *
509 | * - exclude hidden values
510 | *
511 | * @param \Cake\Datasource\EntityInterface|null $entity the entity
512 | * @return array
513 | */
514 | private function getData(?EntityInterface $entity = null): array
515 | {
516 | if ($entity === null) {
517 | return [];
518 | }
519 |
520 | return $entity->extract($entity->getVisible());
521 | }
522 |
523 | /**
524 | * Reset log scope
525 | *
526 | * @return \Cake\ORM\Table|self
527 | */
528 | public function resetLogScope(): Table|self
529 | {
530 | $this->setConfig('scope', $this->getConfig('originalScope'), false);
531 |
532 | return $this->_table;
533 | }
534 |
535 | /**
536 | * @param array|string $key config key
537 | * @param mixed $value set value
538 | * @param bool $merge override
539 | * @return void
540 | */
541 | protected function _configWrite(array|string $key, mixed $value, string|bool $merge = false): void
542 | {
543 | if ($key === 'scope') {
544 | $value = $this->buildScope($value);
545 | }
546 | parent::_configWrite($key, $value, $merge);
547 | }
548 |
549 | /**
550 | * Build scope configuration
551 | *
552 | * @param \Cake\Datasource\EntityInterface|array|array<\Cake\Datasource\EntityInterface>|string $value the scope
553 | * @return array ['Scope.Key' => 'scope id', ...]
554 | */
555 | private function buildScope(string|array|EntityInterface $value): array
556 | {
557 | if (!is_array($value)) {
558 | $value = [$value];
559 | }
560 |
561 | $new = [];
562 | foreach ($value as $key => $arg) {
563 | if (is_string($key) && is_scalar($arg)) {
564 | $new[$key] = $arg;
565 | } elseif (is_string($arg)) {
566 | $new[$arg] = null;
567 | } elseif ($arg instanceof EntityInterface) {
568 | $table = $this->fetchTable($arg->getSource());
569 | $scopeId = $this->getLogTable()->getScopeId($table, $arg);
570 | $new[$table->getRegistryAlias()] = $scopeId;
571 | }
572 | }
573 |
574 | return $new;
575 | }
576 | }
577 |
--------------------------------------------------------------------------------
/tests/TestCase/Model/Behavior/LoggerBehaviorTest.php:
--------------------------------------------------------------------------------
1 | Logger = new LoggerBehavior(new Table());
74 |
75 | $this->Authors = $this->fetchTable('TestApp.Authors', ['className' => AuthorsTable::class]);
76 | $this->Articles = $this->fetchTable('TestApp.Articles', ['className' => ArticlesTable::class]);
77 | $this->Comments = $this->fetchTable('TestApp.Comments', ['className' => CommentsTable::class]);
78 | $this->Users = $this->fetchTable('TestApp.Users', ['className' => UsersTable::class]);
79 | $this->ActivityLogs = $this->fetchTable('Elastic/ActivityLogger.ActivityLogs');
80 | }
81 |
82 | public function tearDown(): void
83 | {
84 | unset($this->Logger, $this->Authors, $this->Articles, $this->Users, $this->Comments, $this->ActivityLogs);
85 |
86 | parent::tearDown();
87 | }
88 |
89 | /**
90 | * Test initial setup
91 | *
92 | * @return void
93 | */
94 | public function testInitialization(): void
95 | {
96 | $this->assertSame([
97 | 'TestApp.Authors' => null,
98 | '\MyApp' => true,
99 | ], $this->Authors->getLogScope(), 'will set system scope');
100 | $this->assertSame([
101 | 'TestApp.Authors' => null,
102 | 'TestApp.Articles' => null,
103 | 'TestApp.Users' => null,
104 | ], $this->Comments->getLogScope(), 'if systemScope = false, will does not set system scope');
105 |
106 | $this->markTestIncomplete('Not cover all');
107 | }
108 |
109 | public function testSave(): void
110 | {
111 | $author = $this->Authors->newEntity([
112 | 'username' => 'foo',
113 | 'password' => 'bar',
114 | ]);
115 | $this->Authors->save($author);
116 | // Saved ActivityLogs
117 | $q = $this->ActivityLogs->find();
118 | $this->assertCount(2, $q->all(), 'record two logs, that the Authors scope and the System scope');
119 |
120 | /** @var ActivityLog $log */
121 | $log = $q->first();
122 | $this->assertSame(LogLevel::INFO, $log->level, 'default log level is `info`');
123 | $this->assertSame(ActivityLog::ACTION_CREATE, $log->action, 'that `create`, it is a new creation');
124 | $this->assertSame('TestApp.Authors', $log->object_model, 'object model is `Author`');
125 | $this->assertSame('5', $log->object_id, 'object id is `5`');
126 | $this->assertEquals([
127 | 'id' => 5,
128 | 'username' => 'foo',
129 | ], $log->data, 'recorded the data at the time of creation');
130 | $this->assertArrayNotHasKey('password', $log->data, 'Does not recorded hidden values');
131 |
132 | // edit
133 | $author->setNew(false);
134 | $author->clean();
135 | $author = $this->Authors->patchEntity($author, ['username' => 'anonymous']);
136 | $this->Authors->save($author);
137 |
138 | // Saved ActivityLogs
139 | $q = $this->ActivityLogs->find()->orderByDesc('id');
140 | $this->assertCount(4, $q->all(), 'record two logs, that the Authors scope and the System scope');
141 |
142 | /** @var ActivityLog $log */
143 | $log = $q->first();
144 | $this->assertSame(LogLevel::INFO, $log->level, 'default log level is `info`');
145 | $this->assertSame(ActivityLog::ACTION_UPDATE, $log->action, 'that `update`, it is a updating');
146 | $this->assertSame('TestApp.Authors', $log->object_model, 'object model is `Author`');
147 | $this->assertSame('5', $log->object_id, 'object id is `5`');
148 | $this->assertEquals([
149 | 'username' => 'anonymous',
150 | ], $log->data, 'recorded the data at the time of updating');
151 | $this->assertArrayNotHasKey('password', $log->data, 'Does not recorded hidden values.');
152 | }
153 |
154 | public function testDelete(): void
155 | {
156 | $author = $this->Authors->get(1);
157 | $this->Authors->delete($author);
158 | // Saved ActivityLogs
159 | $q = $this->ActivityLogs->find();
160 | $this->assertCount(2, $q->all());
161 |
162 | /** @var ActivityLog $log */
163 | $log = $q->first();
164 | $this->assertSame(LogLevel::INFO, $log->level, 'default log level is `info`');
165 | $this->assertSame(ActivityLog::ACTION_DELETE, $log->action, 'that `delete`, it is a deleting');
166 | $this->assertSame('TestApp.Authors', $log->object_model, 'object model is `Author`');
167 | $this->assertSame('1', $log->object_id, 'object id is `1`');
168 | $this->assertEquals([
169 | 'id' => 1,
170 | 'username' => 'mariano',
171 | 'created' => '2007-03-17T01:16:23+00:00',
172 | 'updated' => '2007-03-17T01:18:31+00:00',
173 | ], $log->data, 'recorded the data at the time of deleting');
174 | $this->assertArrayNotHasKey('password', $log->data, 'Does not recorded hidden values.');
175 | }
176 |
177 | public function testLogScope(): void
178 | {
179 | $this->assertSame([
180 | 'TestApp.Authors' => null,
181 | '\MyApp' => true,
182 | ], $this->Authors->getLogScope(), 'can get log scope');
183 | $this->assertSame([
184 | 'TestApp.Articles' => null,
185 | 'TestApp.Authors' => null,
186 | '\MyApp' => true,
187 | ], $this->Articles->getLogScope(), 'can get log scope');
188 |
189 | // Set and get
190 | $author = $this->Authors->get(1);
191 | $this->Authors->setLogScope($author);
192 | $this->assertSame([
193 | 'TestApp.Authors' => 1,
194 | '\MyApp' => true,
195 | ], $this->Authors->getLogScope(), 'updated log scope');
196 | $article = $this->Articles->get(2);
197 | $this->Articles->setLogScope([$article, $author]);
198 | $this->assertSame([
199 | 'TestApp.Articles' => 2,
200 | 'TestApp.Authors' => 1,
201 | '\MyApp' => true,
202 | ], $this->Articles->getLogScope(), 'can get log scope');
203 |
204 | // Add scope
205 | $this->Articles->setLogScope($this->Comments->get(3));
206 | $this->Articles->setLogScope('Custom');
207 | $this->Articles->setLogScope(['Another' => 4, 'Foo' => '005', 'Bar']);
208 | $this->assertSame([
209 | 'TestApp.Articles' => 2,
210 | 'TestApp.Authors' => 1,
211 | '\MyApp' => true,
212 | 'TestApp.Comments' => 3,
213 | 'Custom' => true,
214 | 'Another' => 4,
215 | 'Foo' => '005',
216 | 'Bar' => true,
217 | ], $this->Articles->getLogScope(), 'updated log scope');
218 | // Reset scope
219 | $this->Articles->resetLogScope();
220 | $this->assertSame([
221 | 'TestApp.Articles' => null,
222 | 'TestApp.Authors' => null,
223 | '\MyApp' => true,
224 | ], $this->Articles->getLogScope(), 'will reset log scope');
225 | }
226 |
227 | public function testLogScopeSetterGetter(): void
228 | {
229 | $this->assertSame([
230 | 'TestApp.Authors' => null,
231 | '\MyApp' => true,
232 | ], $this->Authors->getLogScope(), 'can get log scope');
233 | $this->assertSame([
234 | 'TestApp.Articles' => null,
235 | 'TestApp.Authors' => null,
236 | '\MyApp' => true,
237 | ], $this->Articles->getLogScope(), 'can get log scope');
238 |
239 | // Set and get
240 | $author = $this->Authors->get(1);
241 | $this->Authors->setLogScope($author);
242 | $this->assertSame([
243 | 'TestApp.Authors' => 1,
244 | '\MyApp' => true,
245 | ], $this->Authors->getLogScope(), 'updated log scope');
246 | $article = $this->Articles->get(2);
247 | $this->Articles->setLogScope([$article, $author]);
248 | $this->assertSame([
249 | 'TestApp.Articles' => 2,
250 | 'TestApp.Authors' => 1,
251 | '\MyApp' => true,
252 | ], $this->Articles->getLogScope(), 'can get log scope');
253 |
254 | // Add scope
255 | $this->Articles->setLogScope($this->Comments->get(3));
256 | $this->Articles->setLogScope('Custom');
257 | $this->Articles->setLogScope(['Another' => 4, 'Foo' => '005', 'Bar']);
258 | $this->assertSame([
259 | 'TestApp.Articles' => 2,
260 | 'TestApp.Authors' => 1,
261 | '\MyApp' => true,
262 | 'TestApp.Comments' => 3,
263 | 'Custom' => true,
264 | 'Another' => 4,
265 | 'Foo' => '005',
266 | 'Bar' => true,
267 | ], $this->Articles->getLogScope(), 'will reset log scope');
268 | // Reset scope
269 | $this->Articles->resetLogScope();
270 | $this->assertSame([
271 | 'TestApp.Articles' => null,
272 | 'TestApp.Authors' => null,
273 | '\MyApp' => true,
274 | ], $this->Articles->getLogScope(), 'will reset log scope');
275 | }
276 |
277 | public function testSaveWithScope(): void
278 | {
279 | $author = $this->Authors->newEntity([
280 | 'username' => 'foo',
281 | 'password' => 'bar',
282 | ]);
283 | $this->Authors->save($author);
284 | /** @var ActivityLog $log */
285 | $log = $this->ActivityLogs->find()
286 | ->where(['scope_model' => 'TestApp.Authors'])
287 | ->orderByDesc('id')->first();
288 | $this->assertEquals($author->id, $log->scope_id, 'will set scope');
289 | /** @var ActivityLog $log */
290 | $log = $this->ActivityLogs->find()
291 | ->where(['scope_model' => '\MyApp'])
292 | ->orderByDesc('id')->first();
293 | $this->assertEquals(1, $log->scope_id, 'will set scope');
294 |
295 | $article = $this->Articles->get(2);
296 | $user = $this->Users->get(1);
297 | $comment = $this->Comments->newEntity([
298 | 'article_id' => $article->id,
299 | 'user_id' => $user->id,
300 | 'comment' => 'Awesome!',
301 | ]);
302 | $this->Comments->setLogScope([$article, $user]);
303 | $this->Comments->save($comment);
304 |
305 | $logs = $this->ActivityLogs->find()
306 | ->where(['object_model' => 'TestApp.Comments'])
307 | ->orderByDesc('id')
308 | ->all()
309 | ->toArray();
310 |
311 | $this->assertCount(2, $logs);
312 | $this->assertSame('TestApp.Users', $logs[0]->scope_model, 'will set scope model');
313 | $this->assertEquals($user->id, $logs[0]->scope_id, 'will set scope');
314 | $this->assertSame('TestApp.Articles', $logs[1]->scope_model, 'will set scope model');
315 | $this->assertEquals($article->id, $logs[1]->scope_id, 'will set scope');
316 | }
317 |
318 | public function testSaveWithScopeMap(): void
319 | {
320 | $article = $this->Articles->get(2);
321 | $user = $this->Users->get(1);
322 | $comment = $this->Comments->newEntity([
323 | 'article_id' => $article->id,
324 | 'user_id' => $user->id,
325 | 'comment' => 'Awesome!',
326 | ]);
327 | $this->Comments->behaviors()->get('Logger')->setConfig('scopeMap', [
328 | 'article_id' => 'TestApp.Articles',
329 | 'user_id' => 'TestApp.Users',
330 | ]);
331 | $this->Comments->save($comment);
332 |
333 | $logs = $this->ActivityLogs->find()
334 | ->where(['object_model' => 'TestApp.Comments'])
335 | ->orderByDesc('id')
336 | ->all()
337 | ->toArray();
338 |
339 | $this->assertCount(2, $logs);
340 | $this->assertSame('TestApp.Users', $logs[0]->scope_model, 'will set scope model');
341 | $this->assertEquals($user->id, $logs[0]->scope_id, 'will set scope');
342 | $this->assertSame('TestApp.Articles', $logs[1]->scope_model, 'will set scope model');
343 | $this->assertEquals($article->id, $logs[1]->scope_id, 'will set scope id');
344 | }
345 |
346 | public function testSaveWithIssuer(): void
347 | {
348 | $user = $this->Users->get(1);
349 | $this->Authors->setLogIssuer($user);
350 | $author = $this->Authors->newEntity([
351 | 'username' => 'foo',
352 | 'password' => 'bar',
353 | ]);
354 | $this->Authors->save($author);
355 | /** @var ActivityLog $log */
356 | $log = $this->ActivityLogs->find()->orderByDesc('id')->first();
357 | $this->assertSame('TestApp.Users', $log->issuer_model, 'will set issuer model');
358 | $this->assertEquals($user->id, $log->issuer_id, 'will set issuer id');
359 |
360 | $article = $this->Articles->get(2);
361 | $user = $this->Users->get(1);
362 | $comment = $this->Comments->newEntity([
363 | 'article_id' => $article->id,
364 | 'user_id' => $user->id,
365 | 'comment' => 'Awesome!',
366 | ]);
367 | $this->Comments->setLogIssuer($user);
368 | $this->Comments->setLogScope($article);
369 | $this->Comments->save($comment);
370 |
371 | $logs = $this->ActivityLogs->find()
372 | ->where(['object_model' => 'TestApp.Comments'])
373 | ->orderByDesc('id')
374 | ->all()
375 | ->toArray();
376 |
377 | $this->assertCount(2, $logs);
378 | $this->assertSame('TestApp.Users', $logs[0]->scope_model, 'will set scope model from issuer');
379 | $this->assertEquals($user->id, $logs[0]->scope_id, 'will set scope id from issuer');
380 | $this->assertSame('TestApp.Users', $logs[0]->issuer_model, 'will set issuer model');
381 | $this->assertEquals($user->id, $logs[0]->issuer_id, 'will set issuer id');
382 | $this->assertSame('TestApp.Articles', $logs[1]->scope_model, 'will set scope model');
383 | $this->assertEquals($article->id, $logs[1]->scope_id, 'will set scope id');
384 | $this->assertSame('TestApp.Users', $logs[1]->issuer_model, 'will set issuer model');
385 | $this->assertEquals($user->id, $logs[1]->issuer_id, 'will set issuer id');
386 | }
387 |
388 | public function testActivityLog(): void
389 | {
390 | $level = LogLevel::WARNING;
391 | $message = 'custom message';
392 | $user = $this->Users->get(4);
393 | $article = $this->Articles->get(1);
394 | $author = $this->Authors->get(1);
395 | $context = [
396 | 'issuer' => $user,
397 | 'scope' => [$article, $author],
398 | 'object' => $this->Comments->get(2),
399 | 'action' => 'publish',
400 | ];
401 | $this->Comments->activityLog($level, $message, $context);
402 |
403 | $logs = $this->ActivityLogs->find()
404 | ->orderByDesc('id')
405 | ->all()
406 | ->toArray();
407 |
408 | $this->assertCount(3, $logs);
409 | $this->assertSame('TestApp.Users', $logs[0]->scope_model, 'will set scope model');
410 | $this->assertEquals($user->id, $logs[0]->scope_id, 'will set scope id');
411 | $this->assertSame('TestApp.Users', $logs[0]->issuer_model, 'will set issuer model');
412 | $this->assertEquals($user->id, $logs[0]->issuer_id, 'will set issuer id');
413 | $this->assertSame('TestApp.Comments', $logs[0]->object_model);
414 | $this->assertEquals('2', $logs[0]->object_id);
415 | $this->assertSame($message, $logs[0]->message);
416 | $this->assertSame($level, $logs[0]->level);
417 | $this->assertSame('publish', $logs[0]->action);
418 |
419 | $this->assertSame('TestApp.Authors', $logs[1]->scope_model, 'will set scope model');
420 | $this->assertEquals($article->id, $logs[1]->scope_id, 'will set scope id');
421 | $this->assertSame('TestApp.Users', $logs[1]->issuer_model, 'will set issuer model');
422 | $this->assertEquals($user->id, $logs[1]->issuer_id, 'will set issuer id');
423 | $this->assertSame('TestApp.Comments', $logs[1]->object_model);
424 | $this->assertEquals('2', $logs[1]->object_id);
425 | $this->assertSame($message, $logs[1]->message);
426 | $this->assertSame($level, $logs[1]->level);
427 | $this->assertSame('publish', $logs[1]->action);
428 |
429 | $this->assertSame('TestApp.Articles', $logs[2]->scope_model, 'will set scope model');
430 | $this->assertEquals($article->id, $logs[2]->scope_id, 'will set scope id');
431 | $this->assertSame('TestApp.Users', $logs[2]->issuer_model, 'will set issuer model');
432 | $this->assertEquals($user->id, $logs[2]->issuer_id, 'will set issuer id');
433 | $this->assertSame('TestApp.Comments', $logs[2]->object_model);
434 | $this->assertEquals('2', $logs[2]->object_id);
435 | $this->assertSame($message, $logs[2]->message);
436 | $this->assertSame($level, $logs[2]->level);
437 | $this->assertSame('publish', $logs[2]->action);
438 | }
439 |
440 | public function testLogMessageBuilder(): void
441 | {
442 | $this->assertNull($this->Articles->getLogMessageBuilder());
443 | $this->Articles->setLogMessageBuilder(function (ActivityLog $log, array $context) {
444 | if (!empty($log->message)) {
445 | return $log->message;
446 | }
447 |
448 | $message = '';
449 | $object = $context['object'] ?: null;
450 | $issuer = $context['issuer'] ?: null;
451 | switch ($log->action) {
452 | case ActivityLog::ACTION_CREATE:
453 | $message = sprintf('%3$s created article #%1$s "%2$s".', $object->id, $object->title, $issuer->username);
454 | break;
455 | case ActivityLog::ACTION_UPDATE:
456 | $message = sprintf('%3$s updated article #%1$s "%2$s".', $object->id, $object->title, $issuer->username);
457 | break;
458 | case ActivityLog::ACTION_DELETE:
459 | $message = sprintf('%3$s deleted article #%1$s "%2$s".', $object->id, $object->title, $issuer->username);
460 | break;
461 | default:
462 | break;
463 | }
464 |
465 | return $message;
466 | });
467 |
468 | // Create a new article
469 | $author = $this->Authors->get(1);
470 | $article = $this->Articles->newEntity([
471 | 'title' => 'Version 1.0 Release',
472 | 'body' => 'We have released the new version 1.0.',
473 | 'author' => $author,
474 | ]);
475 | $this->Articles->setLogIssuer($author);
476 | $this->Articles->save($article);
477 |
478 | // Update the article
479 | $article->title = 'Version 1.0 stable release';
480 | $this->Articles->save($article);
481 |
482 | // Record custom log
483 | $this->Articles->activityLog(LogLevel::NOTICE, 'Updating the article.');
484 |
485 | // Deleting by another user
486 | $this->Articles->setLogIssuer($this->Authors->get(2));
487 | $this->Articles->delete($article);
488 |
489 | $logs = $this->ActivityLogs->find()
490 | ->where(['scope_model' => 'TestApp.Authors'])
491 | ->orderByAsc('id')
492 | ->all()
493 | ->toArray();
494 |
495 | $this->assertCount(4, $logs);
496 | $this->assertSame('mariano created article #4 "Version 1.0 Release".', $logs[0]->message);
497 | $this->assertSame('mariano updated article #4 "Version 1.0 stable release".', $logs[1]->message);
498 | $this->assertSame('Updating the article.', $logs[2]->message);
499 | $this->assertSame('nate deleted article #4 "Version 1.0 stable release".', $logs[3]->message);
500 | }
501 |
502 | /**
503 | * @return void
504 | */
505 | public function testLogMessageBuilderSetterGetter(): void
506 | {
507 | $this->assertNull($this->Articles->getLogMessageBuilder());
508 | $this->Articles->setLogMessageBuilder(function (ActivityLog $log, array $context) {
509 | if (!empty($log->message)) {
510 | return $log->message;
511 | }
512 |
513 | $message = '';
514 | $object = $context['object'] ?: null;
515 | $issuer = $context['issuer'] ?: null;
516 | switch ($log->action) {
517 | case ActivityLog::ACTION_CREATE:
518 | $message = sprintf('%3$s created article #%1$s "%2$s".', $object->id, $object->title, $issuer->username);
519 | break;
520 | case ActivityLog::ACTION_UPDATE:
521 | $message = sprintf('%3$s updated article #%1$s "%2$s".', $object->id, $object->title, $issuer->username);
522 | break;
523 | case ActivityLog::ACTION_DELETE:
524 | $message = sprintf('%3$s deleted article #%1$s "%2$s".', $object->id, $object->title, $issuer->username);
525 | break;
526 | default:
527 | break;
528 | }
529 |
530 | return $message;
531 | });
532 |
533 | // Create a new article
534 | $author = $this->Authors->get(1);
535 | $article = $this->Articles->newEntity([
536 | 'title' => 'Version 1.0 Release',
537 | 'body' => 'We have released the new version 1.0.',
538 | 'author' => $author,
539 | ]);
540 | $this->Articles->setLogIssuer($author);
541 | $this->Articles->save($article);
542 |
543 | // Update the article
544 | $article->title = 'Version 1.0 stable release';
545 | $this->Articles->save($article);
546 |
547 | // Record custom log
548 | $this->Articles->activityLog(LogLevel::NOTICE, 'Updating the article.');
549 |
550 | // Deleting by another user
551 | $this->Articles->setLogIssuer($this->Authors->get(2));
552 | $this->Articles->delete($article);
553 |
554 | $logs = $this->ActivityLogs->find()
555 | ->where(['scope_model' => 'TestApp.Authors'])
556 | ->orderByAsc('id')
557 | ->all()
558 | ->toArray();
559 |
560 | $this->assertCount(4, $logs);
561 | $this->assertSame('mariano created article #4 "Version 1.0 Release".', $logs[0]->message);
562 | $this->assertSame('mariano updated article #4 "Version 1.0 stable release".', $logs[1]->message);
563 | $this->assertSame('Updating the article.', $logs[2]->message);
564 | $this->assertSame('nate deleted article #4 "Version 1.0 stable release".', $logs[3]->message);
565 | }
566 |
567 | /**
568 | * @return void
569 | */
570 | public function testSetLogMessage(): void
571 | {
572 | $author = $this->Authors->get(1);
573 | $this->Articles->setLogIssuer($author);
574 |
575 | // Create a new article
576 | $this->Articles->setLogMessage('custom message');
577 | $article = $this->Articles->newEntity([
578 | 'title' => 'Version 1.0 Release',
579 | 'body' => 'We have released a new version 1.0.',
580 | 'author' => $author,
581 | ]);
582 | $this->Articles->save($article);
583 |
584 | // Update the article
585 | $article->title = 'Version 1.0 stable release';
586 | $this->Articles->save($article);
587 |
588 | // Update the article
589 | $this->Articles->setLogMessage('persist custom message', true);
590 | $article->title = 'Version 1.0.0 stable release';
591 | $this->Articles->save($article);
592 | // Delete the article
593 | $this->Articles->delete($article);
594 |
595 | $logs = $this->ActivityLogs->find()
596 | ->where(['scope_model' => 'TestApp.Authors'])
597 | ->orderByAsc('id')
598 | ->all()
599 | ->toArray();
600 |
601 | $this->assertCount(4, $logs);
602 | $this->assertSame('custom message', $logs[0]->message);
603 | $this->assertSame('', $logs[1]->message, 'reset message that set from `setLogMessage`, when any log recorded');
604 | $this->assertSame('persist custom message', $logs[2]->message);
605 | $this->assertSame('persist custom message', $logs[3]->message, 'keep message, when persist flag is true');
606 | }
607 |
608 | public function testFindActivity(): void
609 | {
610 | $author = $this->Authors->get(1);
611 | $article = $this->Articles->newEntity([
612 | 'title' => 'new article',
613 | 'body' => 'new content.',
614 | 'author' => $author,
615 | ]);
616 | $this->Articles->setLogIssuer($author)->save($article);
617 | $user = $this->Users->get(2);
618 | $comment = $this->Comments->newEntity([
619 | 'user_id' => $user->id,
620 | 'article_id' => $article->id,
621 | 'comment' => 'new comment',
622 | ]);
623 | $this->Comments->setLogIssuer($user)->setLogScope([$article])->save($comment);
624 |
625 | $authorLogs = $this->Authors->find('activity', scope: $author)
626 | ->all()
627 | ->toArray();
628 | $this->assertCount(1, $authorLogs);
629 | $this->assertSame('TestApp.Articles', $authorLogs[0]->object_model);
630 | $articleLogs = $this->Articles->find('activity', scope: $article)
631 | ->all()
632 | ->toArray();
633 | $this->assertCount(2, $articleLogs);
634 | $this->assertSame(
635 | 'TestApp.Comments',
636 | $articleLogs[0]->object_model,
637 | 'The latest one is displayed above',
638 | );
639 | $this->assertSame('TestApp.Articles', $articleLogs[1]->object_model);
640 | $commentLogs = $this->Comments->find('activity', scope: $comment)
641 | ->all()
642 | ->toArray();
643 | $this->assertCount(0, $commentLogs);
644 | $userLogs = $this->Users->find('activity', scope: $user)
645 | ->all()
646 | ->toArray();
647 | $this->assertCount(1, $userLogs);
648 | $this->assertSame('TestApp.Comments', $userLogs[0]->object_model);
649 | }
650 |
651 | public function testAnotherLogModel(): void
652 | {
653 | $this->Users->activityLog(LogLevel::DEBUG, 'test log');
654 |
655 | $this->Logger->setConfig('logModel', AlterActivityLogsTable::class);
656 | $this->Logger->setConfig('logModelAlias', 'AlterActivityLogs');
657 | $this->Logger->activityLog(LogLevel::DEBUG, 'alter test log');
658 |
659 | $this->assertTrue(true, 'Do not throws any exception');
660 | }
661 |
662 | /**
663 | * @return void
664 | * @covers ::disableActivityLog
665 | * @covers ::enableActivityLog
666 | */
667 | public function testDisableLogging(): void
668 | {
669 | // -- Disable logging
670 | $this->Authors->disableActivityLog();
671 |
672 | // -- Create a new author
673 | $author = $this->Authors->newEntity([
674 | 'username' => 'foo',
675 | 'password' => 'bar',
676 | ]);
677 | $this->Authors->saveOrFail($author);
678 | // Saved ActivityLogs
679 | $this->assertCount(0, $this->ActivityLogs->find()->all(), 'not record logs, because logging is disabled');
680 |
681 | // -- Edit the author
682 | $author->setNew(false);
683 | $author->clean();
684 | $author = $this->Authors->patchEntity($author, ['username' => 'anonymous']);
685 | $this->Authors->saveOrFail($author);
686 |
687 | // Saved ActivityLogs
688 | $this->assertCount(0, $this->ActivityLogs->find()->all(), 'not record logs, because logging is disabled');
689 |
690 | // -- Delete the author
691 | $this->Authors->deleteOrFail($author);
692 |
693 | // Saved ActivityLogs
694 | $this->assertCount(0, $this->ActivityLogs->find()->all(), 'not record logs, because logging is disabled');
695 |
696 | // -- Enable logging
697 | $this->Authors->enableActivityLog();
698 |
699 | // -- Create a new author
700 | $author = $this->Authors->newEntity([
701 | 'username' => 'foo',
702 | 'password' => 'bar',
703 | ]);
704 | $this->Authors->saveOrFail($author);
705 |
706 | // Saved ActivityLogs
707 | $this->assertGreaterThan(0, $this->ActivityLogs->find()->count(), 'record logs, because logging is enabled');
708 | }
709 | }
710 |
--------------------------------------------------------------------------------