Added user id to logs, implemented filter by user

This commit is contained in:
Igor Scheller 2023-12-06 17:35:39 +01:00 committed by xuwhite
parent 162116998c
commit 8185a74edc
14 changed files with 248 additions and 36 deletions

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Migrations;
use Engelsystem\Database\Migration\Migration;
use Illuminate\Database\Schema\Blueprint;
class AddUserIdToLogEntries extends Migration
{
use Reference;
/**
* Run the migration
*/
public function up(): void
{
$this->schema->table('log_entries', function (Blueprint $table): void {
$table->unsignedInteger('user_id')->after('id')->nullable()->default(null);
$table->foreign('user_id')
->references('id')->on('users')
->onUpdate('cascade')
->nullOnDelete();
});
}
/**
* Reverse the migration
*/
public function down(): void
{
$this->schema->table('log_entries', function (Blueprint $table): void {
$table->dropForeign('log_entries_user_id_foreign');
$table->dropColumn('user_id');
});
}
}

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Migrations;
use Engelsystem\Database\Migration\Migration;
class AddLogsAllPermission extends Migration
{
/**
* Run the migration
*/
public function up(): void
{
$db = $this->schema->getConnection();
$db->table('privileges')
->insert([
['name' => 'logs.all', 'description' => 'View all logs'],
]);
$logsAll = $db->table('privileges')
->where('name', 'logs.all')
->get(['id'])
->first();
$bureaucrat = 80;
$db->table('group_privileges')
->insertOrIgnore([
['group_id' => $bureaucrat, 'privilege_id' => $logsAll->id],
]);
}
/**
* Reverse the migration
*/
public function down(): void
{
$db = $this->schema->getConnection();
$db->table('privileges')
->where('name', 'logs.all')
->delete();
}
}

View File

@ -30,7 +30,7 @@ trait Reference
$table->primary($fromColumn); $table->primary($fromColumn);
} }
$this->addReference($table, $fromColumn, $targetTable, $targetColumn ?: 'id'); $this->addReference($table, $fromColumn, $targetTable, $targetColumn);
return $col; return $col;
} }

View File

@ -1407,6 +1407,9 @@ msgstr "Du kannst hier Markdown verwenden"
msgid "form.required" msgid "form.required"
msgstr "Pflichtfeld" msgstr "Pflichtfeld"
msgid "form.user_select"
msgstr "Wähle einen User"
msgid "schedule.import" msgid "schedule.import"
msgstr "Programm importieren" msgstr "Programm importieren"
@ -1531,6 +1534,9 @@ msgstr "Suchen"
msgid "log.log" msgid "log.log"
msgstr "Logs" msgstr "Logs"
msgid "log.only_own"
msgstr "Du siehst hier deine eigenen Logs. Die logs anderer User können nur Bürokraten sehen."
msgid "log.time" msgid "log.time"
msgstr "Zeit" msgstr "Zeit"

View File

@ -115,6 +115,9 @@ msgstr "Required"
msgid "form.markdown" msgid "form.markdown"
msgstr "You can use Markdown here" msgstr "You can use Markdown here"
msgid "form.user_select"
msgstr "Select a user"
msgid "schedule.import" msgid "schedule.import"
msgstr "Import schedule" msgstr "Import schedule"
@ -244,6 +247,9 @@ msgstr "Search"
msgid "log.log" msgid "log.log"
msgstr "Logs" msgstr "Logs"
msgid "log.only_own"
msgstr "You can view your own logs. The logs of other users can be checked by bureaucrats."
msgid "log.time" msgid "log.time"
msgstr "Time" msgstr "Time"

View File

@ -6,7 +6,7 @@
{% block content %} {% block content %}
<div class="col-md-12"> <div class="col-md-12">
<h1>{{ block('title') }}</h1> <h1>{{ block('title') }} <small>({{ entries|length }})</small></h1>
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
@ -14,19 +14,39 @@
<form method="post" action="{{ url('/admin/logs') }}" class="form-inline"> <form method="post" action="{{ url('/admin/logs') }}" class="form-inline">
{{ csrf() }} {{ csrf() }}
<div class="row">
<div class="col-md-8">
{{ f.input('search', __('form.search'), { {{ f.input('search', __('form.search'), {
'value': search, 'value': search,
'hide_label': true, 'hide_label': true,
}) }} }) }}
</div>
{% if has_permission_to('logs.all') %}
<div class="col-md-4">
{{ f.select('search_user_id', __('general.user'), users, {
'default_option': __('form.user_select'),
'selected': search_user_id,
}) }}
</div>
{% endif %}
</div>
{{ f.submit(__('form.search'), {'icon_left': 'search'}) }} {{ f.submit(__('form.search'), {'icon_left': 'search'}) }}
</form> </form>
</div> </div>
{% if not has_permission_to('logs.all') %}
<div class="mb-3">
{{ m.alert(__('log.only_own')) }}
</div>
{% endif %}
<table class="table table-striped"> <table class="table table-striped">
<tr> <tr>
<th>{{ __('log.time') }}</th> <th>{{ __('log.time') }}</th>
<th>{{ __('log.level') }}</th> <th>{{ __('log.level') }}</th>
<th>{{ __('general.user') }}</th>
<th>{{ __('log.message') }}</th> <th>{{ __('log.message') }}</th>
</tr> </tr>
{% for entry in entries %} {% for entry in entries %}
@ -47,10 +67,11 @@
{%- endif %} {%- endif %}
<tr> <tr>
<td class="table-{{ td_type }}">{{ entry.created_at.format(__('general.datetime')) }}</td> <td class="table-{{ td_type }} text-nowrap">{{ entry.created_at.format(__('general.datetime')) }}</td>
<td class="table-{{ td_type }}"> <td class="table-{{ td_type }} text-nowrap">
<span class="badge bg-{{ type }}">{{ entry.level|capitalize }}</span> <!-- //todo bs5 --> <span class="badge bg-{{ type }}">{{ entry.level|capitalize }}</span> <!-- //todo bs5 -->
</td> </td>
<td class="text-nowrap">{% if entry.user %}{{ m.user(entry.user) }}{% endif %}</td>
<td>{{ entry.message|nl2br }}</td> <td>{{ entry.message|nl2br }}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -5,9 +5,12 @@ declare(strict_types=1);
namespace Engelsystem\Controllers\Admin; namespace Engelsystem\Controllers\Admin;
use Engelsystem\Controllers\BaseController; use Engelsystem\Controllers\BaseController;
use Engelsystem\Helpers\Authenticator;
use Engelsystem\Http\Request; use Engelsystem\Http\Request;
use Engelsystem\Http\Response; use Engelsystem\Http\Response;
use Engelsystem\Models\LogEntry; use Engelsystem\Models\LogEntry;
use Engelsystem\Models\User\User;
use Illuminate\Support\Collection;
class LogsController extends BaseController class LogsController extends BaseController
{ {
@ -16,18 +19,33 @@ class LogsController extends BaseController
'admin_log', 'admin_log',
]; ];
public function __construct(protected LogEntry $log, protected Response $response) public function __construct(protected LogEntry $log, protected Response $response, protected Authenticator $auth)
{ {
} }
public function index(Request $request): Response public function index(Request $request): Response
{ {
$searchUserId = (int) $request->input('search_user_id') ?: null;
$search = $request->input('search'); $search = $request->input('search');
$entries = $this->log->filter($search); $userId = $this->auth->user()?->id;
if ($this->auth->can('logs.all')) {
$userId = $searchUserId;
}
$entries = $this->log->filter($search, $userId);
/** @var Collection $users */
$users = User::with('personalData')
->orderBy('name')
->get()
->mapWithKeys(function (User $u) {
return [$u->id => $u->displayName];
});
return $this->response->withView( return $this->response->withView(
'admin/log.twig', 'admin/log.twig',
['entries' => $entries, 'search' => $search] ['entries' => $entries, 'search' => $search, 'users' => $users, 'search_user_id' => $searchUserId]
); );
} }
} }

View File

@ -44,7 +44,7 @@ class Logger extends AbstractLogger
$message .= $this->formatException($context['exception']); $message .= $this->formatException($context['exception']);
} }
$this->log->create(['level' => $level, 'message' => $message]); $this->createEntry(['level' => $level, 'message' => $message]);
} }
/** /**
@ -81,4 +81,9 @@ class Logger extends AbstractLogger
{ {
return in_array($level, $this->allowedLevels); return in_array($level, $this->allowedLevels);
} }
protected function createEntry(array $data): void
{
$this->log->create($data);
}
} }

View File

@ -5,24 +5,21 @@ declare(strict_types=1);
namespace Engelsystem\Logger; namespace Engelsystem\Logger;
use Engelsystem\Helpers\Authenticator; use Engelsystem\Helpers\Authenticator;
use Psr\Log\InvalidArgumentException;
use Stringable;
class UserAwareLogger extends Logger class UserAwareLogger extends Logger
{ {
protected Authenticator $auth; protected ?Authenticator $auth;
/** /**
* Logs with an arbitrary level and prepends the user * Adds the authenticated user to the log message
* @throws InvalidArgumentException
*/ */
public function log(mixed $level, string|Stringable $message, array $context = []): void public function createEntry(array $data): void
{ {
if ($this->auth && ($user = $this->auth->user())) { if ($this->auth && ($user = $this->auth->user())) {
$message = sprintf('%s (%u): %s', $user->name, $user->id, $message); $data['user_id'] = $user->id;
} }
parent::log($level, $message, $context); parent::createEntry($data);
} }
public function setAuth(Authenticator $auth): void public function setAuth(Authenticator $auth): void

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Engelsystem\Models; namespace Engelsystem\Models;
use Carbon\Carbon; use Carbon\Carbon;
use Engelsystem\Models\User\UsesUserModel;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Database\Query\Builder as QueryBuilder;
@ -23,35 +24,48 @@ use Illuminate\Support\Collection as SupportCollection;
*/ */
class LogEntry extends BaseModel class LogEntry extends BaseModel
{ {
use UsesUserModel;
/** @var bool enable timestamps for created_at */ /** @var bool enable timestamps for created_at */
public $timestamps = true; // phpcs:ignore public $timestamps = true; // phpcs:ignore
/** @var null Disable updated_at */ /** @var null Disable updated_at */
public const UPDATED_AT = null; public const UPDATED_AT = null;
/** @var array<string, string> */
protected $casts = [ // phpcs:ignore
'user_id' => 'integer',
];
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
*/ */
protected $fillable = [ // phpcs:ignore protected $fillable = [ // phpcs:ignore
'level', 'level',
'message', 'message',
'user_id',
]; ];
/** /**
* @return Builder[]|Collection|SupportCollection|LogEntry[] * @return Builder[]|Collection|SupportCollection|LogEntry[]
*/ */
public static function filter(string $keyword = null): array|Collection|SupportCollection public static function filter(?string $keyword = null, ?int $userId = null): array|Collection|SupportCollection
{ {
$query = self::query() $query = self::with(['user', 'user.personalData', 'user.state'])
->select()
->orderByDesc('created_at') ->orderByDesc('created_at')
->orderByDesc('id') ->orderByDesc('id')
->limit(10000); ->limit(10000);
if (!empty($userId)) {
$query->where('user_id', $userId);
}
if (!empty($keyword)) { if (!empty($keyword)) {
$query $query
->where('level', '=', $keyword) ->where(function (Builder $query) use ($keyword): void {
$query->where('level', '=', $keyword)
->orWhere('message', 'LIKE', '%' . $keyword . '%'); ->orWhere('message', 'LIKE', '%' . $keyword . '%');
});
} }
return $query->get(); return $query->get();

View File

@ -4,10 +4,13 @@ declare(strict_types=1);
namespace Engelsystem\Test\Unit\Controllers\Admin; namespace Engelsystem\Test\Unit\Controllers\Admin;
use Engelsystem\Config\Config;
use Engelsystem\Controllers\Admin\LogsController; use Engelsystem\Controllers\Admin\LogsController;
use Engelsystem\Helpers\Authenticator;
use Engelsystem\Http\Request; use Engelsystem\Http\Request;
use Engelsystem\Http\Response; use Engelsystem\Http\Response;
use Engelsystem\Models\LogEntry; use Engelsystem\Models\LogEntry;
use Engelsystem\Models\User\User;
use Engelsystem\Test\Unit\HasDatabase; use Engelsystem\Test\Unit\HasDatabase;
use Engelsystem\Test\Unit\TestCase; use Engelsystem\Test\Unit\TestCase;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@ -25,28 +28,77 @@ class LogsControllerTest extends TestCase
{ {
$log = new LogEntry(); $log = new LogEntry();
$alert = $log->create(['level' => LogLevel::ALERT, 'message' => 'Alert test']); $alert = $log->create(['level' => LogLevel::ALERT, 'message' => 'Alert test']);
$alert = $log->find($alert)->first(); $alert = $log->with('user')->find($alert)->first();
$error = $log->create(['level' => LogLevel::ERROR, 'message' => 'Error test']); $error = $log->create(['level' => LogLevel::ERROR, 'message' => 'Error test']);
$error = $log->find($error)->first(); $error = $log->with('user')->find($error)->first();
$auth = $this->createMock(Authenticator::class);
$this->setExpects($auth, 'user', null, null, 2);
$this->setExpects($auth, 'can', ['logs.all'], true, 2);
$response = $this->createMock(Response::class); $response = $this->createMock(Response::class);
$response->expects($this->exactly(2)) $response->expects($this->exactly(2))
->method('withView') ->method('withView')
->withConsecutive( ->withConsecutive(
['admin/log.twig', ['entries' => new Collection([$error, $alert]), 'search' => null]], ['admin/log.twig', [
['admin/log.twig', ['entries' => new Collection([$error]), 'search' => 'error']] 'entries' => new Collection([$error, $alert]),
'search' => null,
'users' => new Collection(),
'search_user_id' => null,
]],
['admin/log.twig', [
'entries' => new Collection([$error]),
'search' => 'error',
'users' => new Collection(),
'search_user_id' => null,
]]
) )
->willReturn($response); ->willReturn($response);
$request = Request::create('/'); $request = Request::create('/');
$controller = new LogsController($log, $response); $controller = new LogsController($log, $response, $auth);
$controller->index($request); $controller->index($request);
$request->request->set('search', 'error'); $request->request->set('search', 'error');
$controller->index($request); $controller->index($request);
} }
/**
* @covers \Engelsystem\Controllers\Admin\LogsController::index
*/
public function testIndexUser(): void
{
User::factory()->create();
$user = User::with(['personalData', 'state'])->first();
$log = new LogEntry();
$alert = $log->create(['level' => LogLevel::ALERT, 'message' => 'Users message', 'user_id' => $user->id]);
/** @var LogEntry $alert */
$alert = $log->with('user')->find($alert)->first();
$log->create(['level' => LogLevel::ERROR, 'message' => 'Error test']);
$auth = $this->createMock(Authenticator::class);
$this->setExpects($auth, 'user', null, $user);
$this->setExpects($auth, 'can', ['logs.all'], false);
$response = $this->createMock(Response::class);
$response->expects($this->once())
->method('withView')
->willReturnCallback(function (string $view, array $data) use ($alert, $response) {
$this->assertEquals('admin/log.twig', $view);
$this->assertArrayHasKey('entries', $data);
$this->assertCount(1, $data['entries']);
$this->assertEquals($alert->message, $data['entries'][0]['message']);
return $response;
});
$request = Request::create('/');
$controller = new LogsController($log, $response, $auth);
$controller->index($request);
}
/** /**
* Set up the DB * Set up the DB
*/ */
@ -55,5 +107,6 @@ class LogsControllerTest extends TestCase
parent::setUp(); parent::setUp();
$this->initDatabase(); $this->initDatabase();
$this->app->instance('config', new Config([]));
} }
} }

View File

@ -45,6 +45,7 @@ class LoggerTest extends ServiceProviderTest
/** /**
* @covers \Engelsystem\Logger\Logger::log * @covers \Engelsystem\Logger\Logger::log
* @covers \Engelsystem\Logger\Logger::createEntry
* @dataProvider provideLogLevels * @dataProvider provideLogLevels
*/ */
public function testAllLevels(string $level): void public function testAllLevels(string $level): void

View File

@ -8,18 +8,23 @@ use Engelsystem\Helpers\Authenticator;
use Engelsystem\Logger\UserAwareLogger; use Engelsystem\Logger\UserAwareLogger;
use Engelsystem\Models\LogEntry; use Engelsystem\Models\LogEntry;
use Engelsystem\Models\User\User; use Engelsystem\Models\User\User;
use Engelsystem\Test\Unit\ServiceProviderTest; use Engelsystem\Test\Unit\HasDatabase;
use Engelsystem\Test\Unit\TestCase;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LogLevel; use Psr\Log\LogLevel;
class UserAwareLoggerTest extends ServiceProviderTest class UserAwareLoggerTest extends TestCase
{ {
use HasDatabase;
/** /**
* @covers \Engelsystem\Logger\UserAwareLogger::log * @covers \Engelsystem\Logger\UserAwareLogger::createEntry
* @covers \Engelsystem\Logger\UserAwareLogger::setAuth * @covers \Engelsystem\Logger\UserAwareLogger::setAuth
*/ */
public function testLog(): void public function testLog(): void
{ {
$this->initDatabase(); // To be able to run the test by itself
$user = User::factory(['id' => 1, 'name' => 'admin'])->make(); $user = User::factory(['id' => 1, 'name' => 'admin'])->make();
/** @var LogEntry|MockObject $logEntry */ /** @var LogEntry|MockObject $logEntry */
@ -30,7 +35,7 @@ class UserAwareLoggerTest extends ServiceProviderTest
->method('create') ->method('create')
->withConsecutive( ->withConsecutive(
[['level' => LogLevel::INFO, 'message' => 'Some more informational foo']], [['level' => LogLevel::INFO, 'message' => 'Some more informational foo']],
[['level' => LogLevel::INFO, 'message' => 'admin (1): Some even more informational bar']] [['level' => LogLevel::INFO, 'message' => 'Some even more informational bar', 'user_id' => 1]]
); );
/** @var Authenticator|MockObject $auth */ /** @var Authenticator|MockObject $auth */

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Engelsystem\Test\Unit\Models; namespace Engelsystem\Test\Unit\Models;
use Engelsystem\Models\LogEntry; use Engelsystem\Models\LogEntry;
use Engelsystem\Models\User\User;
use Psr\Log\LogLevel; use Psr\Log\LogLevel;
class LogEntryTest extends ModelTest class LogEntryTest extends ModelTest
@ -14,6 +15,8 @@ class LogEntryTest extends ModelTest
*/ */
public function testFilter(): void public function testFilter(): void
{ {
$user = User::factory()->create();
(new LogEntry(['level' => LogLevel::DEBUG, 'message' => 'Some users fault', 'user_id' => $user->id]))->save();
foreach ( foreach (
[ [
'I\'m an info' => LogLevel::INFO, 'I\'m an info' => LogLevel::INFO,
@ -31,9 +34,10 @@ class LogEntryTest extends ModelTest
(new LogEntry(['level' => $level, 'message' => $message]))->save(); (new LogEntry(['level' => $level, 'message' => $message]))->save();
} }
$this->assertCount(10, LogEntry::filter()); $this->assertCount(11, LogEntry::filter());
$this->assertCount(3, LogEntry::filter(LogLevel::INFO)); $this->assertCount(3, LogEntry::filter(LogLevel::INFO));
$this->assertCount(1, LogEntry::filter('Oops')); $this->assertCount(1, LogEntry::filter('Oops'));
$this->assertCount(1, LogEntry::filter(null, $user->id));
/** @var LogEntry $first */ /** @var LogEntry $first */
$first = LogEntry::filter()->first(); $first = LogEntry::filter()->first();