Added basic log entry info page

This commit is contained in:
Jan Böhmer 2023-04-10 00:30:23 +02:00
parent e0e5fb3d5a
commit 4107535b19
8 changed files with 339 additions and 136 deletions

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\DataTables\Column\LogEntryTargetColumn;
use App\DataTables\Filters\LogFilter; use App\DataTables\Filters\LogFilter;
use App\DataTables\LogDataTable; use App\DataTables\LogDataTable;
use App\Entity\Base\AbstractDBElement; use App\Entity\Base\AbstractDBElement;
@ -33,6 +34,9 @@ use App\Entity\LogSystem\ElementEditedLogEntry;
use App\Form\Filters\LogFilterType; use App\Form\Filters\LogFilterType;
use App\Repository\DBElementRepository; use App\Repository\DBElementRepository;
use App\Services\LogSystem\EventUndoHelper; use App\Services\LogSystem\EventUndoHelper;
use App\Services\LogSystem\LogEntryExtraFormatter;
use App\Services\LogSystem\LogLevelHelper;
use App\Services\LogSystem\LogTargetHelper;
use App\Services\LogSystem\TimeTravel; use App\Services\LogSystem\TimeTravel;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
@ -93,6 +97,28 @@ class LogController extends AbstractController
]); ]);
} }
/**
* @Route("/{id}/details", name="log_details")
* @param Request $request
* @param AbstractLogEntry $logEntry
* @return Response
*/
public function logDetails(Request $request, AbstractLogEntry $logEntry, LogEntryExtraFormatter $logEntryExtraFormatter,
LogLevelHelper $logLevelHelper, LogTargetHelper $logTargetHelper): Response
{
$this->denyAccessUnlessGranted('read', $logEntry);
$extra_html = $logEntryExtraFormatter->format($logEntry);
$target_html = $logTargetHelper->formatTarget($logEntry);
return $this->render('log_system/details/log_details.html.twig', [
'log_entry' => $logEntry,
'extra_html' => $extra_html,
'target_html' => $target_html,
'log_level_helper' => $logLevelHelper,
]);
}
/** /**
* @Route("/undo", name="log_undo", methods={"POST"}) * @Route("/undo", name="log_undo", methods={"POST"})
*/ */

View file

@ -36,6 +36,7 @@ use App\Exceptions\EntityNotSupportedException;
use App\Repository\LogEntryRepository; use App\Repository\LogEntryRepository;
use App\Services\ElementTypeNameGenerator; use App\Services\ElementTypeNameGenerator;
use App\Services\EntityURLGenerator; use App\Services\EntityURLGenerator;
use App\Services\LogSystem\LogTargetHelper;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Omines\DataTablesBundle\Column\AbstractColumn; use Omines\DataTablesBundle\Column\AbstractColumn;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@ -43,21 +44,11 @@ use Symfony\Contracts\Translation\TranslatorInterface;
class LogEntryTargetColumn extends AbstractColumn class LogEntryTargetColumn extends AbstractColumn
{ {
protected EntityManagerInterface $em; private LogTargetHelper $logTargetHelper;
protected LogEntryRepository $entryRepository;
protected EntityURLGenerator $entityURLGenerator;
protected ElementTypeNameGenerator $elementTypeNameGenerator;
protected TranslatorInterface $translator;
public function __construct(EntityManagerInterface $entityManager, EntityURLGenerator $entityURLGenerator, public function __construct(LogTargetHelper $logTargetHelper)
ElementTypeNameGenerator $elementTypeNameGenerator, TranslatorInterface $translator)
{ {
$this->em = $entityManager; $this->logTargetHelper = $logTargetHelper;
$this->entryRepository = $entityManager->getRepository(AbstractLogEntry::class);
$this->entityURLGenerator = $entityURLGenerator;
$this->elementTypeNameGenerator = $elementTypeNameGenerator;
$this->translator = $translator;
} }
/** /**
@ -80,71 +71,9 @@ class LogEntryTargetColumn extends AbstractColumn
public function render($value, $context): string public function render($value, $context): string
{ {
if ($context instanceof UserNotAllowedLogEntry && $this->options['showAccessDeniedPath']) { return $this->logTargetHelper->formatTarget($context, [
return htmlspecialchars($context->getPath()); 'showAccessDeniedPath' => $this->options['showAccessDeniedPath'],
} 'show_associated' => $this->options['show_associated'],
]);
/** @var AbstractLogEntry $context */
$target = $this->entryRepository->getTargetElement($context);
$tmp = '';
//The element is existing
if ($target instanceof NamedElementInterface && !empty($target->getName())) {
try {
$tmp = sprintf(
'<a href="%s">%s</a>',
$this->entityURLGenerator->infoURL($target),
$this->elementTypeNameGenerator->getTypeNameCombination($target, true)
);
} catch (EntityNotSupportedException $exception) {
$tmp = $this->elementTypeNameGenerator->getTypeNameCombination($target, true);
}
} elseif ($target instanceof AbstractDBElement) { //Target does not have a name
$tmp = sprintf(
'<i>%s</i>: %s',
$this->elementTypeNameGenerator->getLocalizedTypeLabel($target),
$target->getID()
);
} elseif (null === $target && $context->hasTarget()) { //Element was deleted
$tmp = sprintf(
'<i>%s</i>: %s [%s]',
$this->elementTypeNameGenerator->getLocalizedTypeLabel($context->getTargetClass()),
$context->getTargetID(),
$this->translator->trans('log.target_deleted')
);
}
//Add a hint to the associated element if possible
if (null !== $target && $this->options['show_associated']) {
if ($target instanceof Attachment && null !== $target->getElement()) {
$on = $target->getElement();
} elseif ($target instanceof AbstractParameter && null !== $target->getElement()) {
$on = $target->getElement();
} elseif ($target instanceof PartLot && null !== $target->getPart()) {
$on = $target->getPart();
} elseif ($target instanceof Orderdetail && null !== $target->getPart()) {
$on = $target->getPart();
} elseif ($target instanceof Pricedetail && null !== $target->getOrderdetail() && null !== $target->getOrderdetail()->getPart()) {
$on = $target->getOrderdetail()->getPart();
} elseif ($target instanceof ProjectBOMEntry && null !== $target->getProject()) {
$on = $target->getProject();
}
if (isset($on) && is_object($on)) {
try {
$tmp .= sprintf(
' (<a href="%s">%s</a>)',
$this->entityURLGenerator->infoURL($on),
$this->elementTypeNameGenerator->getTypeNameCombination($on, true)
);
} catch (EntityNotSupportedException $exception) {
$tmp .= ' ('.$this->elementTypeNameGenerator->getTypeNameCombination($target, true).')';
}
}
}
//Log is not associated with an element
return $tmp;
} }
} }

View file

@ -44,6 +44,7 @@ use App\Exceptions\EntityNotSupportedException;
use App\Repository\LogEntryRepository; use App\Repository\LogEntryRepository;
use App\Services\ElementTypeNameGenerator; use App\Services\ElementTypeNameGenerator;
use App\Services\EntityURLGenerator; use App\Services\EntityURLGenerator;
use App\Services\LogSystem\LogLevelHelper;
use App\Services\UserSystem\UserAvatarHelper; use App\Services\UserSystem\UserAvatarHelper;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
@ -70,10 +71,11 @@ class LogDataTable implements DataTableTypeInterface
protected LogEntryRepository $logRepo; protected LogEntryRepository $logRepo;
protected Security $security; protected Security $security;
protected UserAvatarHelper $userAvatarHelper; protected UserAvatarHelper $userAvatarHelper;
protected LogLevelHelper $logLevelHelper;
public function __construct(ElementTypeNameGenerator $elementTypeNameGenerator, TranslatorInterface $translator, public function __construct(ElementTypeNameGenerator $elementTypeNameGenerator, TranslatorInterface $translator,
UrlGeneratorInterface $urlGenerator, EntityURLGenerator $entityURLGenerator, EntityManagerInterface $entityManager, UrlGeneratorInterface $urlGenerator, EntityURLGenerator $entityURLGenerator, EntityManagerInterface $entityManager,
Security $security, UserAvatarHelper $userAvatarHelper) Security $security, UserAvatarHelper $userAvatarHelper, LogLevelHelper $logLevelHelper)
{ {
$this->elementTypeNameGenerator = $elementTypeNameGenerator; $this->elementTypeNameGenerator = $elementTypeNameGenerator;
$this->translator = $translator; $this->translator = $translator;
@ -82,6 +84,7 @@ class LogDataTable implements DataTableTypeInterface
$this->logRepo = $entityManager->getRepository(AbstractLogEntry::class); $this->logRepo = $entityManager->getRepository(AbstractLogEntry::class);
$this->security = $security; $this->security = $security;
$this->userAvatarHelper = $userAvatarHelper; $this->userAvatarHelper = $userAvatarHelper;
$this->logLevelHelper = $logLevelHelper;
} }
public function configureOptions(OptionsResolver $optionsResolver): void public function configureOptions(OptionsResolver $optionsResolver): void
@ -115,69 +118,18 @@ class LogDataTable implements DataTableTypeInterface
//This special $$rowClass column is used to set the row class depending on the log level. The class gets set by the frontend controller //This special $$rowClass column is used to set the row class depending on the log level. The class gets set by the frontend controller
$dataTable->add('dont_matter', RowClassColumn::class, [ $dataTable->add('dont_matter', RowClassColumn::class, [
'render' => static function ($value, AbstractLogEntry $context) { 'render' => function ($value, AbstractLogEntry $context) {
switch ($context->getLevel()) { return $this->logLevelHelper->logLevelToTableColorClass($context->getLevelString());
case AbstractLogEntry::LEVEL_EMERGENCY:
case AbstractLogEntry::LEVEL_ALERT:
case AbstractLogEntry::LEVEL_CRITICAL:
case AbstractLogEntry::LEVEL_ERROR:
return 'table-danger';
case AbstractLogEntry::LEVEL_WARNING:
return 'table-warning';
case AbstractLogEntry::LEVEL_NOTICE:
return 'table-info';
default:
return '';
}
}, },
]); ]);
$dataTable->add('symbol', TextColumn::class, [ $dataTable->add('symbol', TextColumn::class, [
'label' => '', 'label' => '',
'className' => 'no-colvis', 'className' => 'no-colvis',
'render' => static function ($value, AbstractLogEntry $context) { 'render' => function ($value, AbstractLogEntry $context) {
switch ($context->getLevelString()) {
case LogLevel::DEBUG:
$symbol = 'fa-bug';
break;
case LogLevel::INFO:
$symbol = 'fa-info';
break;
case LogLevel::NOTICE:
$symbol = 'fa-flag';
break;
case LogLevel::WARNING:
$symbol = 'fa-exclamation-circle';
break;
case LogLevel::ERROR:
$symbol = 'fa-exclamation-triangle';
break;
case LogLevel::CRITICAL:
$symbol = 'fa-bolt';
break;
case LogLevel::ALERT:
$symbol = 'fa-radiation';
break;
case LogLevel::EMERGENCY:
$symbol = 'fa-skull-crossbones';
break;
default:
$symbol = 'fa-question-circle';
break;
}
return sprintf( return sprintf(
'<i class="fas fa-fw %s" title="%s"></i>', '<i class="fas fa-fw %s" title="%s"></i>',
$symbol, $this->logLevelHelper->logLevelToIconClass($context->getLevelString()),
$context->getLevelString() $context->getLevelString()
); );
}, },
@ -191,6 +143,12 @@ class LogDataTable implements DataTableTypeInterface
$dataTable->add('timestamp', LocaleDateTimeColumn::class, [ $dataTable->add('timestamp', LocaleDateTimeColumn::class, [
'label' => 'log.timestamp', 'label' => 'log.timestamp',
'timeFormat' => 'medium', 'timeFormat' => 'medium',
'render' => function (string $value, AbstractLogEntry $context) {
return sprintf('<a href="%s">%s</a>',
$this->urlGenerator->generate('log_details', ['id' => $context->getId()]),
$value
);
}
]); ]);
$dataTable->add('type', TextColumn::class, [ $dataTable->add('type', TextColumn::class, [

View file

@ -53,7 +53,7 @@ class LogEntryVoter extends ExtendedVoter
protected function supports($attribute, $subject): bool protected function supports($attribute, $subject): bool
{ {
if ($subject instanceof AbstractLogEntry) { if ($subject instanceof AbstractLogEntry) {
return in_array($subject, static::ALLOWED_OPS, true); return in_array($attribute, static::ALLOWED_OPS, true);
} }
return false; return false;

View file

@ -0,0 +1,80 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Services\LogSystem;
use App\Entity\LogSystem\AbstractLogEntry;
use Psr\Log\LogLevel;
class LogLevelHelper
{
/**
* Returns the FontAwesome icon class for the given log level.
* This returns just the specific icon class (so 'fa-info' for example).
* @param string $logLevel The string representation of the log level (one of the LogLevel::* constants)
* @return string
*/
public function logLevelToIconClass(string $logLevel): string
{
switch ($logLevel) {
case LogLevel::DEBUG:
return 'fa-bug';
case LogLevel::INFO:
return 'fa-info';
case LogLevel::NOTICE:
return 'fa-flag';
case LogLevel::WARNING:
return 'fa-exclamation-circle';
case LogLevel::ERROR:
return 'fa-exclamation-triangle';
case LogLevel::CRITICAL:
return 'fa-bolt';
case LogLevel::ALERT:
return 'fa-radiation';
case LogLevel::EMERGENCY:
return 'fa-skull-crossbones';
default:
return 'fa-question-circle';
}
}
/**
* Returns the Bootstrap table color class for the given log level.
* @param string $logLevel The string representation of the log level (one of the LogLevel::* constants)
* @return string The table color class (one of the 'table-*' classes)
*/
public function logLevelToTableColorClass(string $logLevel): string
{
switch ($logLevel) {
case LogLevel::EMERGENCY:
case LogLevel::ALERT:
case LogLevel::CRITICAL:
case LogLevel::ERROR:
return 'table-danger';
case LogLevel::WARNING:
return 'table-warning';
case LogLevel::NOTICE:
return 'table-info';
default:
return '';
}
}
}

View file

@ -0,0 +1,141 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Services\LogSystem;
use App\Entity\Attachments\Attachment;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Contracts\NamedElementInterface;
use App\Entity\LogSystem\AbstractLogEntry;
use App\Entity\LogSystem\UserNotAllowedLogEntry;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parts\PartLot;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\PriceInformations\Pricedetail;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Exceptions\EntityNotSupportedException;
use App\Repository\LogEntryRepository;
use App\Services\ElementTypeNameGenerator;
use App\Services\EntityURLGenerator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
class LogTargetHelper
{
protected EntityManagerInterface $em;
protected LogEntryRepository $entryRepository;
protected EntityURLGenerator $entityURLGenerator;
protected ElementTypeNameGenerator $elementTypeNameGenerator;
protected TranslatorInterface $translator;
public function __construct(EntityManagerInterface $entityManager, EntityURLGenerator $entityURLGenerator,
ElementTypeNameGenerator $elementTypeNameGenerator, TranslatorInterface $translator)
{
$this->em = $entityManager;
$this->entryRepository = $entityManager->getRepository(AbstractLogEntry::class);
$this->entityURLGenerator = $entityURLGenerator;
$this->elementTypeNameGenerator = $elementTypeNameGenerator;
$this->translator = $translator;
}
private function configureOptions(OptionsResolver $resolver): self
{
$resolver->setDefault('show_associated', true);
$resolver->setDefault('showAccessDeniedPath', true);
return $this;
}
public function formatTarget(AbstractLogEntry $context, array $options = []): string
{
$optionsResolver = new OptionsResolver();
$this->configureOptions($optionsResolver);
$options = $optionsResolver->resolve($options);
if ($context instanceof UserNotAllowedLogEntry && $options['showAccessDeniedPath']) {
return htmlspecialchars($context->getPath());
}
/** @var AbstractLogEntry $context */
$target = $this->entryRepository->getTargetElement($context);
$tmp = '';
//The element is existing
if ($target instanceof NamedElementInterface && !empty($target->getName())) {
try {
$tmp = sprintf(
'<a href="%s">%s</a>',
$this->entityURLGenerator->infoURL($target),
$this->elementTypeNameGenerator->getTypeNameCombination($target, true)
);
} catch (EntityNotSupportedException $exception) {
$tmp = $this->elementTypeNameGenerator->getTypeNameCombination($target, true);
}
} elseif ($target instanceof AbstractDBElement) { //Target does not have a name
$tmp = sprintf(
'<i>%s</i>: %s',
$this->elementTypeNameGenerator->getLocalizedTypeLabel($target),
$target->getID()
);
} elseif (null === $target && $context->hasTarget()) { //Element was deleted
$tmp = sprintf(
'<i>%s</i>: %s [%s]',
$this->elementTypeNameGenerator->getLocalizedTypeLabel($context->getTargetClass()),
$context->getTargetID(),
$this->translator->trans('log.target_deleted')
);
}
//Add a hint to the associated element if possible
if (null !== $target && $options['show_associated']) {
if ($target instanceof Attachment && null !== $target->getElement()) {
$on = $target->getElement();
} elseif ($target instanceof AbstractParameter && null !== $target->getElement()) {
$on = $target->getElement();
} elseif ($target instanceof PartLot && null !== $target->getPart()) {
$on = $target->getPart();
} elseif ($target instanceof Orderdetail && null !== $target->getPart()) {
$on = $target->getPart();
} elseif ($target instanceof Pricedetail && null !== $target->getOrderdetail() && null !== $target->getOrderdetail()->getPart()) {
$on = $target->getOrderdetail()->getPart();
} elseif ($target instanceof ProjectBOMEntry && null !== $target->getProject()) {
$on = $target->getProject();
}
if (isset($on) && is_object($on)) {
try {
$tmp .= sprintf(
' (<a href="%s">%s</a>)',
$this->entityURLGenerator->infoURL($on),
$this->elementTypeNameGenerator->getTypeNameCombination($on, true)
);
} catch (EntityNotSupportedException $exception) {
$tmp .= ' ('.$this->elementTypeNameGenerator->getTypeNameCombination($target, true).')';
}
}
}
//Log is not associated with an element
return $tmp;
}
}

View file

@ -0,0 +1,63 @@
{% extends "main_card.html.twig" %}
{% import "helper.twig" as helper %}
{% block title %}
{% trans %}log.details.title{% endtrans %}:
{{ ('log.type.' ~ log_entry.type) | trans }} ({{ log_entry.timestamp | format_datetime('short') }})
{% endblock %}
{% block card_title %}
<i class="fas fa-binoculars"></i>
{% trans %}log.details.title{% endtrans %}:
<i>{{ ('log.type.' ~ log_entry.type) | trans }}</i> ({{ log_entry.timestamp | format_datetime('short') }})
<span class="float-end">ID: {{ log_entry.iD }}</span>
{% endblock %}
{% block card_body %}
<table class="table table-striped table-hover mb-0 {{ log_level_helper.logLevelToTableColorClass(log_entry.levelString) }}">
<tr>
<td>{% trans %}log.timestamp{% endtrans %}</td>
<td>{{ log_entry.timestamp | format_datetime('full') }}</td>
</tr>
<tr>
<td>{% trans %}log.type{% endtrans %}</td>
<td>
{{ ('log.type.' ~ log_entry.type) | trans }}
{% if log_entry.type == 'part_stock_changed' %}
({{ ('log.part_stock_changed.' ~ log_entry.instockChangeType)|trans }})
{% endif %}
</td>
</tr>
<tr>
<td>{% trans %}log.level{% endtrans %}</td>
<td>
<i class="fa-solid {{ log_level_helper.logLevelToIconClass(log_entry.levelString) }} fa-fw"></i>
{{ ('log.level.'~ log_entry.levelString)|trans }}
</td>
</tr>
<tr>
<td>{% trans %}log.user{% endtrans %}
<td>
{% if log_entry.cLIEntry %}
<i class="fa-solid fa-terminal"></i>
{{ log_entry.cLIUsername }} ({% trans %}log.cli_user{% endtrans %})
{% else %}
{% if log_entry.user %}
{{ helper.user_icon_link(log_entry.user) }} (@{{ log_entry.user.username }})
{% else %}
@{{ log_entry.username }} ({% trans %}log.target_deleted{% endtrans %}
{% endif %}
{% endif %}
</td>
</tr>
<tr>
<td>{% trans %}log.target{% endtrans %}</td>
<td>{{ target_html|raw }}</td>
</tr>
</table>
<div class="card-body">
{{ extra_html | raw }}
</div>
{% endblock %}

View file

@ -11289,5 +11289,11 @@ Element 3</target>
<target>Show email on public profile page</target> <target>Show email on public profile page</target>
</segment> </segment>
</unit> </unit>
<unit id="4rkjIk2" name="log.details.title">
<segment>
<source>log.details.title</source>
<target>Log details</target>
</segment>
</unit>
</file> </file>
</xliff> </xliff>