Show element history on part info page in history tab.

This commit is contained in:
Jan Böhmer 2020-02-22 20:04:43 +01:00
parent fff1864a68
commit c14d6d91ff
12 changed files with 331 additions and 26 deletions

View file

@ -42,6 +42,7 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\DataTables\LogDataTable;
use App\Entity\Parts\Category; use App\Entity\Parts\Category;
use App\Entity\Parts\Part; use App\Entity\Parts\Part;
use App\Exceptions\AttachmentDownloadException; use App\Exceptions\AttachmentDownloadException;
@ -49,9 +50,11 @@ use App\Form\Part\PartBaseType;
use App\Services\Attachments\AttachmentManager; use App\Services\Attachments\AttachmentManager;
use App\Services\Attachments\AttachmentSubmitHandler; use App\Services\Attachments\AttachmentSubmitHandler;
use App\Services\Attachments\PartPreviewGenerator; use App\Services\Attachments\PartPreviewGenerator;
use App\Services\LogSystem\HistoryHelper;
use App\Services\LogSystem\TimeTravel; use App\Services\LogSystem\TimeTravel;
use App\Services\PricedetailHelper; use App\Services\PricedetailHelper;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Omines\DataTablesBundle\DataTableFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
@ -82,11 +85,23 @@ class PartController extends AbstractController
* *
* @param Part $part * @param Part $part
* @return Response * @return Response
* @throws \Exception
*/ */
public function show(Part $part, TimeTravel $timeTravel, ?string $timestamp = null): Response public function show(Part $part, Request $request, TimeTravel $timeTravel, HistoryHelper $historyHelper,
DataTableFactory $dataTable, ?string $timestamp = null): Response
{ {
$this->denyAccessUnlessGranted('read', $part); $this->denyAccessUnlessGranted('read', $part);
$table = $dataTable->createFromType(LogDataTable::class, [
'filter_elements' => $historyHelper->getAssociatedElements($part),
'mode' => 'element_history'
], ['pageLength' => 10])
->handleRequest($request);
if ($table->isCallback()) {
return $table->getResponse();
}
$timeTravel_timestamp = null; $timeTravel_timestamp = null;
if ($timestamp !== null) { if ($timestamp !== null) {
//If the timestamp only contains numbers interpret it as unix timestamp //If the timestamp only contains numbers interpret it as unix timestamp
@ -103,6 +118,7 @@ class PartController extends AbstractController
'Parts/info/show_part_info.html.twig', 'Parts/info/show_part_info.html.twig',
[ [
'part' => $part, 'part' => $part,
'datatable' => $table,
'attachment_helper' => $this->attachmentManager, 'attachment_helper' => $this->attachmentManager,
'pricedetail_helper' => $this->pricedetailHelper, 'pricedetail_helper' => $this->pricedetailHelper,
'pictures' => $this->partPreviewGenerator->getPreviewAttachments($part), 'pictures' => $this->partPreviewGenerator->getPreviewAttachments($part),

View file

@ -0,0 +1,108 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2020 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\DataTables\Column;
use Omines\DataTablesBundle\Column\AbstractColumn;
use Symfony\Component\OptionsResolver\OptionsResolver;
class IconLinkColumn extends AbstractColumn
{
/**
* @inheritDoc
*/
public function normalize($value)
{
return $value;
}
public function configureOptions(OptionsResolver $resolver)
{
parent::configureOptions($resolver);
$resolver->setDefaults([
'icon' => 'fas fa-fw fa-edit',
'title' => null,
'href' => null,
]);
$resolver->setAllowedTypes('title', ['null', 'string', 'callable']);
$resolver->setAllowedTypes('icon', ['null', 'string', 'callable']);
$resolver->setAllowedTypes('href', ['null', 'string', 'callable']);
}
public function render($value, $context)
{
$href = $this->getHref($value, $context);
$icon = $this->getIcon($value, $context);
$title = $this->getTitle($value, $context);
if ($href !== null) {
return sprintf(
'<a href="%s" title="%s"><i class="%s"></i></a>',
$href,
$title,
$icon
);
}
return "";
}
protected function getHref($value, $context): ?string
{
$provider = $this->options['href'];
if (is_string($provider)) {
return $provider;
}
if (is_callable($provider)) {
return call_user_func($provider, $value, $context);
}
return null;
}
protected function getIcon($value, $context): ?string
{
$provider = $this->options['icon'];
if (is_string($provider)) {
return $provider;
}
if (is_callable($provider)) {
return call_user_func($provider, $value, $context);
}
return null;
}
protected function getTitle($value, $context): ?string
{
$provider = $this->options['title'];
if (is_string($provider)) {
return $provider;
}
if (is_callable($provider)) {
return call_user_func($provider, $value, $context);
}
return null;
}
}

View file

@ -42,36 +42,63 @@ declare(strict_types=1);
namespace App\DataTables; namespace App\DataTables;
use App\DataTables\Column\IconLinkColumn;
use App\DataTables\Column\LocaleDateTimeColumn; use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\LogEntryExtraColumn; use App\DataTables\Column\LogEntryExtraColumn;
use App\DataTables\Column\LogEntryTargetColumn; use App\DataTables\Column\LogEntryTargetColumn;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Contracts\TimeTravelInterface;
use App\Entity\LogSystem\AbstractLogEntry; use App\Entity\LogSystem\AbstractLogEntry;
use App\Exceptions\EntityNotSupportedException;
use App\Services\ElementTypeNameGenerator; use App\Services\ElementTypeNameGenerator;
use App\Services\EntityURLGenerator;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter; use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter;
use Omines\DataTablesBundle\Column\TextColumn; use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTable; use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableTypeInterface; use Omines\DataTablesBundle\DataTableTypeInterface;
use Psr\Log\LogLevel; use Psr\Log\LogLevel;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Flex\Options;
class LogDataTable implements DataTableTypeInterface class LogDataTable implements DataTableTypeInterface
{ {
protected $elementTypeNameGenerator; protected $elementTypeNameGenerator;
protected $translator; protected $translator;
protected $urlGenerator; protected $urlGenerator;
protected $entityURLGenerator;
protected $logRepo;
public function __construct(ElementTypeNameGenerator $elementTypeNameGenerator, TranslatorInterface $translator, public function __construct(ElementTypeNameGenerator $elementTypeNameGenerator, TranslatorInterface $translator,
UrlGeneratorInterface $urlGenerator) UrlGeneratorInterface $urlGenerator, EntityURLGenerator $entityURLGenerator, EntityManagerInterface $entityManager)
{ {
$this->elementTypeNameGenerator = $elementTypeNameGenerator; $this->elementTypeNameGenerator = $elementTypeNameGenerator;
$this->translator = $translator; $this->translator = $translator;
$this->urlGenerator = $urlGenerator; $this->urlGenerator = $urlGenerator;
$this->entityURLGenerator = $entityURLGenerator;
$this->logRepo = $entityManager->getRepository(AbstractLogEntry::class);
}
public function configureOptions(OptionsResolver $optionsResolver)
{
$optionsResolver->setDefaults([
'mode' => 'system_log',
'filter_elements' => [],
]);
$optionsResolver->setAllowedValues('mode', ['system_log', 'element_history']);
} }
public function configure(DataTable $dataTable, array $options): void public function configure(DataTable $dataTable, array $options): void
{ {
$resolver = new OptionsResolver();
$this->configureOptions($resolver);
$options = $resolver->resolve($options);
$dataTable->add('symbol', TextColumn::class, [ $dataTable->add('symbol', TextColumn::class, [
'label' => '', 'label' => '',
'render' => function ($value, AbstractLogEntry $context) { 'render' => function ($value, AbstractLogEntry $context) {
@ -138,6 +165,7 @@ class LogDataTable implements DataTableTypeInterface
$dataTable->add('level', TextColumn::class, [ $dataTable->add('level', TextColumn::class, [
'label' => $this->translator->trans('log.level'), 'label' => $this->translator->trans('log.level'),
'visible' => $options['mode'] === 'system_log',
'propertyPath' => 'levelString', 'propertyPath' => 'levelString',
'render' => function (string $value, AbstractLogEntry $context) { 'render' => function (string $value, AbstractLogEntry $context) {
return $value; return $value;
@ -178,21 +206,51 @@ class LogDataTable implements DataTableTypeInterface
'label' => $this->translator->trans('log.extra'), 'label' => $this->translator->trans('log.extra'),
]); ]);
$dataTable->add('timeTravel', IconLinkColumn::class,[
'label' => '',
'icon' => 'fas fa-fw fa-eye',
'href' => function ($value, AbstractLogEntry $context) {
if (
$context instanceof TimeTravelInterface
&& $context->hasOldDataInformations()
) {
try {
$target = $this->logRepo->getTargetElement($context);
$str = $this->entityURLGenerator->timeTravelURL($target, $context->getTimestamp());
return $str;
} catch (EntityNotSupportedException $exception) {
return null;
}
}
return null;
}
]);
$dataTable->addOrderBy('timestamp', DataTable::SORT_DESCENDING); $dataTable->addOrderBy('timestamp', DataTable::SORT_DESCENDING);
$dataTable->createAdapter(ORMAdapter::class, [ $dataTable->createAdapter(ORMAdapter::class, [
'entity' => AbstractLogEntry::class, 'entity' => AbstractLogEntry::class,
'query' => function (QueryBuilder $builder): void { 'query' => function (QueryBuilder $builder) use ($options): void {
$this->getQuery($builder); $this->getQuery($builder, $options);
}, },
]); ]);
} }
protected function getQuery(QueryBuilder $builder): void protected function getQuery(QueryBuilder $builder, array $options): void
{ {
$builder->distinct()->select('log') $builder->distinct()->select('log')
->addSelect('user') ->addSelect('user')
->from(AbstractLogEntry::class, 'log') ->from(AbstractLogEntry::class, 'log')
->leftJoin('log.user', 'user'); ->leftJoin('log.user', 'user');
if (!empty($options['filter_elements'])) {
foreach ($options['filter_elements'] as $element) {
/** @var AbstractDBElement $element */
$target_type = AbstractLogEntry::targetTypeClassToID(get_class($element));
$target_id = $element->getID();
$builder->orWhere("log.target_type = $target_type AND log.target_id = $target_id");
}
}
} }
} }

View file

@ -90,7 +90,7 @@ class LogEntryRepository extends EntityRepository
->where('log INSTANCE OF ' . ElementEditedLogEntry::class) ->where('log INSTANCE OF ' . ElementEditedLogEntry::class)
->andWhere('log.target_type = :target_type') ->andWhere('log.target_type = :target_type')
->andWhere('log.target_id = :target_id') ->andWhere('log.target_id = :target_id')
->andWhere('log.timestamp >= :until') ->andWhere('log.timestamp > :until')
->orderBy('log.timestamp', 'DESC'); ->orderBy('log.timestamp', 'DESC');
$qb->setParameters([ $qb->setParameters([

View file

@ -44,6 +44,7 @@ namespace App\Services;
use App\Entity\Attachments\Attachment; use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType; use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\PartAttachment;
use App\Entity\Base\AbstractDBElement; use App\Entity\Base\AbstractDBElement;
use App\Entity\Devices\Device; use App\Entity\Devices\Device;
use App\Entity\Parts\Category; use App\Entity\Parts\Category;
@ -51,9 +52,12 @@ use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part; use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\Parts\Storelocation; use App\Entity\Parts\Storelocation;
use App\Entity\Parts\Supplier; use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency; use App\Entity\PriceInformations\Currency;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\PriceInformations\Pricedetail;
use App\Entity\UserSystem\Group; use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User; use App\Entity\UserSystem\User;
use App\Exceptions\EntityNotSupportedException; use App\Exceptions\EntityNotSupportedException;
@ -120,6 +124,49 @@ class EntityURLGenerator
throw new InvalidArgumentException('Method is not supported!'); throw new InvalidArgumentException('Method is not supported!');
} }
/**
* Gets the URL to view the given element at a given timestamp
* @param $entity
* @param \DateTime $dateTime
* @return string
*/
public function timeTravelURL($entity, \DateTime $dateTime): string
{
if ($entity instanceof Part) {
return $this->urlGenerator->generate('part_info', [
'id' => $entity->getID(),
'timestamp' => $dateTime->getTimestamp()
]);
}
if ($entity instanceof PartLot) {
return $this->urlGenerator->generate('part_info', [
'id' => $entity->getPart()->getID(),
'timestamp' => $dateTime->getTimestamp()
]);
}
if ($entity instanceof PartAttachment) {
return $this->urlGenerator->generate('part_info', [
'id' => $entity->getElement()->getID(),
'timestamp' => $dateTime->getTimestamp()
]);
}
if ($entity instanceof Orderdetail) {
return $this->urlGenerator->generate('part_info', [
'id' => $entity->getPart()->getID(),
'timestamp' => $dateTime->getTimestamp()
]);
}
if ($entity instanceof Pricedetail) {
return $this->urlGenerator->generate('part_info', [
'id' => $entity->getOrderdetail()->getPart()->getID(),
'timestamp' => $dateTime->getTimestamp()
]);
}
//Otherwise throw an error
throw new EntityNotSupportedException('The given entity is not supported yet!');
}
public function viewURL($entity): string public function viewURL($entity): string
{ {
if ($entity instanceof Attachment) { if ($entity instanceof Attachment) {

View file

@ -0,0 +1,57 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2020 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\Base\AbstractDBElement;
use App\Entity\Parts\Part;
class HistoryHelper
{
public function __construct()
{
}
/**
* Returns an array containing all elements that are associated with the argument.
* The returned array contains the given element.
* @param AbstractDBElement $element
* @return array
*/
public function getAssociatedElements(AbstractDBElement $element): array
{
$array = [$element];
if ($element instanceof Part) {
$array = array_merge(
$array,
$element->getAttachments()->toArray(),
$element->getPartLots()->toArray(),
$element->getOrderdetails()->toArray()
);
foreach ($element->getOrderdetails() as $orderdetail) {
$array = array_merge($array, $orderdetail->getPricedetails()->toArray());
}
}
return $array;
}
}

View file

@ -0,0 +1,3 @@
<div class="mt-2">
{% include "Parts/lists/_parts_list.html.twig" %}
</div>

View file

@ -23,7 +23,9 @@
</h5> </h5>
<h3>{{ part.name }} <h3>{{ part.name }}
{# You need edit permission to use the edit button #} {# You need edit permission to use the edit button #}
{% if is_granted('edit', part) %} {% if timeTravel is not null %}
<a href="{{ part|entityURL('info') }}"><i title="{% trans %}part.back_to_info{% endtrans %}" class="fas fa-fw fa-undo"></i></a>
{% elseif is_granted('edit', part) %}
<a href="{{ part|entityURL('edit') }}"><i class="fas fa-fw fa-sm fa-edit"></i></a> <a href="{{ part|entityURL('edit') }}"><i class="fas fa-fw fa-sm fa-edit"></i></a>
{% endif %} {% endif %}
</h3> </h3>

View file

@ -1,5 +1,9 @@
{% import "helper.twig" as helper %} {% import "helper.twig" as helper %}
{% if timeTravel is not null %}
<b class="mb-2">{% trans with {'%timestamp%': timeTravel|format_datetime('short')} %}part.info.timetravel_hint{% endtrans %}</b>
{% endif %}
<div class="mb-3"> <div class="mb-3">
<span class="text-muted" title="{% trans %}lastModified{% endtrans %}"> <span class="text-muted" title="{% trans %}lastModified{% endtrans %}">
<i class="fas fa-history fa-fw"></i> {{ helper.date_user_combination(part, true) }} <i class="fas fa-history fa-fw"></i> {{ helper.date_user_combination(part, true) }}

View file

@ -114,13 +114,11 @@
</div> </div>
<div class="tab-pane fade" id="history" role="tabpanel" aria-labelledby="history-tab"> <div class="tab-pane fade" id="history" role="tabpanel" aria-labelledby="history-tab">
TODO {% include "Parts/info/_history.html.twig" %}
</div> </div>
<div class="tab-pane fade" id="tools" role="tabpanel" aria-labelledby="tools-tab"> <div class="tab-pane fade" id="tools" role="tabpanel" aria-labelledby="tools-tab">
{% include "Parts/info/_tools.html.twig" %} {% include "Parts/info/_tools.html.twig" %}
</div> </div>
<div class="tab-pane fade" id="extended_info" role="tabpanel" aria-labelledby="extended_info-tab"> <div class="tab-pane fade" id="extended_info" role="tabpanel" aria-labelledby="extended_info-tab">

View file

@ -6395,5 +6395,11 @@ Element 3</target>
<target>Element gelöscht</target> <target>Element gelöscht</target>
</segment> </segment>
</unit> </unit>
<unit id="tagdXMa" name="part.info.timetravel_hint">
<segment>
<source>part.info.timetravel_hint</source>
<target>part.info.timetravel_hint</target>
</segment>
</unit>
</file> </file>
</xliff> </xliff>

View file

@ -6373,5 +6373,11 @@ Element 3</target>
<target>System log</target> <target>System log</target>
</segment> </segment>
</unit> </unit>
<unit id="tagdXMa" name="part.info.timetravel_hint">
<segment>
<source>part.info.timetravel_hint</source>
<target><![CDATA[This is how the part appeared on %timestamp%. <i>Please note that this feature is experimental, so the infos are maybe not correct.</i>]]></target>
</segment>
</unit>
</file> </file>
</xliff> </xliff>