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);
}
$this->addReference($table, $fromColumn, $targetTable, $targetColumn ?: 'id');
$this->addReference($table, $fromColumn, $targetTable, $targetColumn);
return $col;
}

View File

@ -1407,6 +1407,9 @@ msgstr "Du kannst hier Markdown verwenden"
msgid "form.required"
msgstr "Pflichtfeld"
msgid "form.user_select"
msgstr "Wähle einen User"
msgid "schedule.import"
msgstr "Programm importieren"
@ -1531,6 +1534,9 @@ msgstr "Suchen"
msgid "log.log"
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"
msgstr "Zeit"

View File

@ -115,6 +115,9 @@ msgstr "Required"
msgid "form.markdown"
msgstr "You can use Markdown here"
msgid "form.user_select"
msgstr "Select a user"
msgid "schedule.import"
msgstr "Import schedule"
@ -244,6 +247,9 @@ msgstr "Search"
msgid "log.log"
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"
msgstr "Time"

View File

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

View File

@ -5,9 +5,12 @@ declare(strict_types=1);
namespace Engelsystem\Controllers\Admin;
use Engelsystem\Controllers\BaseController;
use Engelsystem\Helpers\Authenticator;
use Engelsystem\Http\Request;
use Engelsystem\Http\Response;
use Engelsystem\Models\LogEntry;
use Engelsystem\Models\User\User;
use Illuminate\Support\Collection;
class LogsController extends BaseController
{
@ -16,18 +19,33 @@ class LogsController extends BaseController
'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
{
$searchUserId = (int) $request->input('search_user_id') ?: null;
$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(
'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']);
}
$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);
}
protected function createEntry(array $data): void
{
$this->log->create($data);
}
}

View File

@ -5,24 +5,21 @@ declare(strict_types=1);
namespace Engelsystem\Logger;
use Engelsystem\Helpers\Authenticator;
use Psr\Log\InvalidArgumentException;
use Stringable;
class UserAwareLogger extends Logger
{
protected Authenticator $auth;
protected ?Authenticator $auth;
/**
* Logs with an arbitrary level and prepends the user
* @throws InvalidArgumentException
* Adds the authenticated user to the log message
*/
public function log(mixed $level, string|Stringable $message, array $context = []): void
public function createEntry(array $data): void
{
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

View File

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

View File

@ -4,10 +4,13 @@ declare(strict_types=1);
namespace Engelsystem\Test\Unit\Controllers\Admin;
use Engelsystem\Config\Config;
use Engelsystem\Controllers\Admin\LogsController;
use Engelsystem\Helpers\Authenticator;
use Engelsystem\Http\Request;
use Engelsystem\Http\Response;
use Engelsystem\Models\LogEntry;
use Engelsystem\Models\User\User;
use Engelsystem\Test\Unit\HasDatabase;
use Engelsystem\Test\Unit\TestCase;
use Illuminate\Database\Eloquent\Collection;
@ -25,22 +28,36 @@ class LogsControllerTest extends TestCase
{
$log = new LogEntry();
$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->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->expects($this->exactly(2))
->method('withView')
->withConsecutive(
['admin/log.twig', ['entries' => new Collection([$error, $alert]), 'search' => null]],
['admin/log.twig', ['entries' => new Collection([$error]), 'search' => 'error']]
['admin/log.twig', [
'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);
$request = Request::create('/');
$controller = new LogsController($log, $response);
$controller = new LogsController($log, $response, $auth);
$controller->index($request);
$request->request->set('search', 'error');
@ -48,12 +65,48 @@ class LogsControllerTest extends TestCase
}
/**
* Setup the DB
* @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
*/
public function setUp(): void
{
parent::setUp();
$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::createEntry
* @dataProvider provideLogLevels
*/
public function testAllLevels(string $level): void

View File

@ -8,18 +8,23 @@ use Engelsystem\Helpers\Authenticator;
use Engelsystem\Logger\UserAwareLogger;
use Engelsystem\Models\LogEntry;
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 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
*/
public function testLog(): void
{
$this->initDatabase(); // To be able to run the test by itself
$user = User::factory(['id' => 1, 'name' => 'admin'])->make();
/** @var LogEntry|MockObject $logEntry */
@ -30,7 +35,7 @@ class UserAwareLoggerTest extends ServiceProviderTest
->method('create')
->withConsecutive(
[['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 */

View File

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