mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2025-06-22 01:49:05 +02:00
Show element history on part info page in history tab.
This commit is contained in:
parent
fff1864a68
commit
c14d6d91ff
12 changed files with 331 additions and 26 deletions
|
@ -42,6 +42,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\DataTables\LogDataTable;
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Exceptions\AttachmentDownloadException;
|
||||
|
@ -49,9 +50,11 @@ use App\Form\Part\PartBaseType;
|
|||
use App\Services\Attachments\AttachmentManager;
|
||||
use App\Services\Attachments\AttachmentSubmitHandler;
|
||||
use App\Services\Attachments\PartPreviewGenerator;
|
||||
use App\Services\LogSystem\HistoryHelper;
|
||||
use App\Services\LogSystem\TimeTravel;
|
||||
use App\Services\PricedetailHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Omines\DataTablesBundle\DataTableFactory;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
|
@ -82,11 +85,23 @@ class PartController extends AbstractController
|
|||
*
|
||||
* @param Part $part
|
||||
* @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);
|
||||
|
||||
$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;
|
||||
if ($timestamp !== null) {
|
||||
//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',
|
||||
[
|
||||
'part' => $part,
|
||||
'datatable' => $table,
|
||||
'attachment_helper' => $this->attachmentManager,
|
||||
'pricedetail_helper' => $this->pricedetailHelper,
|
||||
'pictures' => $this->partPreviewGenerator->getPreviewAttachments($part),
|
||||
|
|
108
src/DataTables/Column/IconLinkColumn.php
Normal file
108
src/DataTables/Column/IconLinkColumn.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -42,36 +42,63 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\DataTables;
|
||||
|
||||
use App\DataTables\Column\IconLinkColumn;
|
||||
use App\DataTables\Column\LocaleDateTimeColumn;
|
||||
use App\DataTables\Column\LogEntryExtraColumn;
|
||||
use App\DataTables\Column\LogEntryTargetColumn;
|
||||
use App\Entity\Base\AbstractDBElement;
|
||||
use App\Entity\Contracts\TimeTravelInterface;
|
||||
use App\Entity\LogSystem\AbstractLogEntry;
|
||||
use App\Exceptions\EntityNotSupportedException;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter;
|
||||
use Omines\DataTablesBundle\Column\TextColumn;
|
||||
use Omines\DataTablesBundle\DataTable;
|
||||
use Omines\DataTablesBundle\DataTableTypeInterface;
|
||||
use Psr\Log\LogLevel;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Symfony\Flex\Options;
|
||||
|
||||
class LogDataTable implements DataTableTypeInterface
|
||||
{
|
||||
protected $elementTypeNameGenerator;
|
||||
protected $translator;
|
||||
protected $urlGenerator;
|
||||
protected $entityURLGenerator;
|
||||
protected $logRepo;
|
||||
|
||||
public function __construct(ElementTypeNameGenerator $elementTypeNameGenerator, TranslatorInterface $translator,
|
||||
UrlGeneratorInterface $urlGenerator)
|
||||
UrlGeneratorInterface $urlGenerator, EntityURLGenerator $entityURLGenerator, EntityManagerInterface $entityManager)
|
||||
{
|
||||
$this->elementTypeNameGenerator = $elementTypeNameGenerator;
|
||||
$this->translator = $translator;
|
||||
$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
|
||||
{
|
||||
$resolver = new OptionsResolver();
|
||||
$this->configureOptions($resolver);
|
||||
$options = $resolver->resolve($options);
|
||||
|
||||
|
||||
$dataTable->add('symbol', TextColumn::class, [
|
||||
'label' => '',
|
||||
'render' => function ($value, AbstractLogEntry $context) {
|
||||
|
@ -138,6 +165,7 @@ class LogDataTable implements DataTableTypeInterface
|
|||
|
||||
$dataTable->add('level', TextColumn::class, [
|
||||
'label' => $this->translator->trans('log.level'),
|
||||
'visible' => $options['mode'] === 'system_log',
|
||||
'propertyPath' => 'levelString',
|
||||
'render' => function (string $value, AbstractLogEntry $context) {
|
||||
return $value;
|
||||
|
@ -178,21 +206,51 @@ class LogDataTable implements DataTableTypeInterface
|
|||
'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->createAdapter(ORMAdapter::class, [
|
||||
'entity' => AbstractLogEntry::class,
|
||||
'query' => function (QueryBuilder $builder): void {
|
||||
$this->getQuery($builder);
|
||||
'query' => function (QueryBuilder $builder) use ($options): void {
|
||||
$this->getQuery($builder, $options);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getQuery(QueryBuilder $builder): void
|
||||
protected function getQuery(QueryBuilder $builder, array $options): void
|
||||
{
|
||||
$builder->distinct()->select('log')
|
||||
->addSelect('user')
|
||||
->from(AbstractLogEntry::class, 'log')
|
||||
->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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -90,7 +90,7 @@ class LogEntryRepository extends EntityRepository
|
|||
->where('log INSTANCE OF ' . ElementEditedLogEntry::class)
|
||||
->andWhere('log.target_type = :target_type')
|
||||
->andWhere('log.target_id = :target_id')
|
||||
->andWhere('log.timestamp >= :until')
|
||||
->andWhere('log.timestamp > :until')
|
||||
->orderBy('log.timestamp', 'DESC');
|
||||
|
||||
$qb->setParameters([
|
||||
|
|
|
@ -44,6 +44,7 @@ namespace App\Services;
|
|||
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\AttachmentType;
|
||||
use App\Entity\Attachments\PartAttachment;
|
||||
use App\Entity\Base\AbstractDBElement;
|
||||
use App\Entity\Devices\Device;
|
||||
use App\Entity\Parts\Category;
|
||||
|
@ -51,9 +52,12 @@ use App\Entity\Parts\Footprint;
|
|||
use App\Entity\Parts\Manufacturer;
|
||||
use App\Entity\Parts\MeasurementUnit;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\PartLot;
|
||||
use App\Entity\Parts\Storelocation;
|
||||
use App\Entity\Parts\Supplier;
|
||||
use App\Entity\PriceInformations\Currency;
|
||||
use App\Entity\PriceInformations\Orderdetail;
|
||||
use App\Entity\PriceInformations\Pricedetail;
|
||||
use App\Entity\UserSystem\Group;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Exceptions\EntityNotSupportedException;
|
||||
|
@ -120,6 +124,49 @@ class EntityURLGenerator
|
|||
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
|
||||
{
|
||||
if ($entity instanceof Attachment) {
|
||||
|
|
57
src/Services/LogSystem/HistoryHelper.php
Normal file
57
src/Services/LogSystem/HistoryHelper.php
Normal 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;
|
||||
}
|
||||
}
|
3
templates/Parts/info/_history.html.twig
Normal file
3
templates/Parts/info/_history.html.twig
Normal file
|
@ -0,0 +1,3 @@
|
|||
<div class="mt-2">
|
||||
{% include "Parts/lists/_parts_list.html.twig" %}
|
||||
</div>
|
|
@ -23,7 +23,9 @@
|
|||
</h5>
|
||||
<h3>{{ part.name }}
|
||||
{# 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>
|
||||
{% endif %}
|
||||
</h3>
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
{% 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">
|
||||
<span class="text-muted" title="{% trans %}lastModified{% endtrans %}">
|
||||
<i class="fas fa-history fa-fw"></i> {{ helper.date_user_combination(part, true) }}
|
||||
|
|
|
@ -114,13 +114,11 @@
|
|||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="history" role="tabpanel" aria-labelledby="history-tab">
|
||||
TODO
|
||||
{% include "Parts/info/_history.html.twig" %}
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tools" role="tabpanel" aria-labelledby="tools-tab">
|
||||
|
||||
{% include "Parts/info/_tools.html.twig" %}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="extended_info" role="tabpanel" aria-labelledby="extended_info-tab">
|
||||
|
|
|
@ -6395,5 +6395,11 @@ Element 3</target>
|
|||
<target>Element gelöscht</target>
|
||||
</segment>
|
||||
</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>
|
||||
</xliff>
|
||||
|
|
|
@ -6373,5 +6373,11 @@ Element 3</target>
|
|||
<target>System log</target>
|
||||
</segment>
|
||||
</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>
|
||||
</xliff>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue