Added some simple time travel mechanism for part view.

In the moment it is not possible to show elements that were deleted.
This commit is contained in:
Jan Böhmer 2020-02-16 23:48:57 +01:00
parent a9fd1f9c68
commit 464a487a17
15 changed files with 450 additions and 27 deletions

View file

@ -244,6 +244,16 @@ showing the sidebar (on devices with md or higher)
* Bootstrap extensions
*****************************************/
.bg-primary-striped {
background: repeating-linear-gradient(
-45deg,
var(--primary),
var(--primary) 10px,
var(--info) 10px,
var(--info) 20px
)
}
.form-group-sm {
margin-bottom: 5px;
}

View file

@ -31,6 +31,7 @@ use App\Form\Part\PartBaseType;
use App\Services\Attachments\AttachmentManager;
use App\Services\Attachments\AttachmentSubmitHandler;
use App\Services\Attachments\PartPreviewGenerator;
use App\Services\LogSystem\TimeTravel;
use App\Services\PricedetailHelper;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -46,27 +47,48 @@ use Symfony\Contracts\Translation\TranslatorInterface;
*/
class PartController extends AbstractController
{
protected $attachmentManager;
protected $pricedetailHelper;
protected $partPreviewGenerator;
public function __construct(AttachmentManager $attachmentManager, PricedetailHelper $pricedetailHelper, PartPreviewGenerator $partPreviewGenerator)
{
$this->attachmentManager = $attachmentManager;
$this->pricedetailHelper = $pricedetailHelper;
$this->partPreviewGenerator = $partPreviewGenerator;
}
/**
* @Route("/{id}/info", name="part_info")
* @Route("/{id}/info/{timestamp}", name="part_info")
* @Route("/{id}", requirements={"id"="\d+"})
*
* @param Part $part
* @param AttachmentManager $attachmentHelper
* @param PricedetailHelper $pricedetailHelper
* @param PartPreviewGenerator $previewGenerator
* @return Response
*/
public function show(Part $part, AttachmentManager $attachmentHelper, PricedetailHelper $pricedetailHelper, PartPreviewGenerator $previewGenerator): Response
public function show(Part $part, TimeTravel $timeTravel, ?string $timestamp = null): Response
{
$this->denyAccessUnlessGranted('read', $part);
$timeTravel_timestamp = null;
if ($timestamp !== null) {
//If the timestamp only contains numbers interpret it as unix timestamp
if (ctype_digit($timestamp)) {
$timeTravel_timestamp = new \DateTime();
$timeTravel_timestamp->setTimestamp((int) $timestamp);
} else { //Try to parse it via DateTime
$timeTravel_timestamp = new \DateTime($timestamp);
}
$timeTravel->revertEntityToTimestamp($part, $timeTravel_timestamp);
}
return $this->render(
'Parts/info/show_part_info.html.twig',
[
'part' => $part,
'attachment_helper' => $attachmentHelper,
'pricedetail_helper' => $pricedetailHelper,
'pictures' => $previewGenerator->getPreviewAttachments($part),
'attachment_helper' => $this->attachmentManager,
'pricedetail_helper' => $this->pricedetailHelper,
'pictures' => $this->partPreviewGenerator->getPreviewAttachments($part),
'timeTravel' => $timeTravel_timestamp
]
);
}
@ -83,7 +105,7 @@ class PartController extends AbstractController
* @return Response
*/
public function edit(Part $part, Request $request, EntityManagerInterface $em, TranslatorInterface $translator,
AttachmentManager $attachmentHelper, AttachmentSubmitHandler $attachmentSubmitHandler): Response
AttachmentSubmitHandler $attachmentSubmitHandler): Response
{
$this->denyAccessUnlessGranted('edit', $part);
@ -123,7 +145,7 @@ class PartController extends AbstractController
[
'part' => $part,
'form' => $form->createView(),
'attachment_helper' => $attachmentHelper,
'attachment_helper' => $this->attachmentManager,
]);
}

View file

@ -56,7 +56,7 @@ use Symfony\Component\Serializer\Annotation\Groups;
* "user" = "App\Entity\User"
* })
*/
abstract class AbstractDBElement
abstract class AbstractDBElement implements \JsonSerializable
{
/** @var int|null The Identification number for this part. This value is unique for the element in this table.
* Null if the element is not saved to DB yet.
@ -93,4 +93,9 @@ abstract class AbstractDBElement
* @return string The ID as a string;
*/
abstract public function getIDString(): string;
public function jsonSerialize()
{
return ['@id' => $this->getID()];
}
}

View file

@ -24,6 +24,7 @@ declare(strict_types=1);
namespace App\Entity\Base;
use App\Entity\Contracts\NamedElementInterface;
use App\Entity\Contracts\TimeStampableInterface;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
@ -34,7 +35,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* @ORM\MappedSuperclass(repositoryClass="App\Repository\UserRepository")
* @ORM\HasLifecycleCallbacks()
*/
abstract class AbstractNamedDBElement extends AbstractDBElement implements NamedElementInterface
abstract class AbstractNamedDBElement extends AbstractDBElement implements NamedElementInterface, TimeStampableInterface
{
use TimestampTrait;

View file

@ -0,0 +1,44 @@
<?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 General Public License
* as published by the Free Software Foundation; either version 2
* 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
*/
namespace App\Entity\Contracts;
use DateTime;
interface TimeStampableInterface
{
/**
* Returns the last time when the element was modified.
* Returns null if the element was not yet saved to DB yet.
*
* @return DateTime|null the time of the last edit
*/
public function getLastModified(): ?DateTime;
/**
* Returns the date/time when the element was created.
* Returns null if the element was not yet saved to DB yet.
*
* @return DateTime|null the creation time of the part
*/
public function getAddedDate(): ?DateTime;
}

View file

@ -0,0 +1,41 @@
<?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 General Public License
* as published by the Free Software Foundation; either version 2
* 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
*/
namespace App\Entity\Contracts;
interface TimeTravelInterface
{
/**
* Checks if this entry has informations which data has changed.
* @return bool True if this entry has informations about the changed data.
*/
public function hasOldDataInformations(): bool;
/** Returns the data the entity had before this log entry. */
public function getOldData(): array;
/**
* Returns the the timestamp associated with this change.
* @return \DateTime
*/
public function getTimestamp(): \DateTime;
}

View file

@ -26,12 +26,13 @@ namespace App\Entity\LogSystem;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Contracts\NamedElementInterface;
use App\Entity\Contracts\TimeTravelInterface;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity()
*/
class ElementDeletedLogEntry extends AbstractLogEntry
class ElementDeletedLogEntry extends AbstractLogEntry implements TimeTravelInterface
{
protected $typeString = 'element_deleted';
@ -65,4 +66,31 @@ class ElementDeletedLogEntry extends AbstractLogEntry
{
return $this->extra['n'] ?? null;
}
/**
* Sets the old data for this entry.
* @param array $old_data
* @return $this
*/
public function setOldData(array $old_data): self
{
$this->extra['o'] = $old_data;
return $this;
}
/**
* @inheritDoc
*/
public function hasOldDataInformations(): bool
{
return !empty($this->extra['d']);
}
/**
* @inheritDoc
*/
public function getOldData(): array
{
return $this->extra['d'] ?? [];
}
}

View file

@ -25,12 +25,13 @@ declare(strict_types=1);
namespace App\Entity\LogSystem;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Contracts\TimeTravelInterface;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity()
*/
class ElementEditedLogEntry extends AbstractLogEntry
class ElementEditedLogEntry extends AbstractLogEntry implements TimeTravelInterface
{
protected $typeString = 'element_edited';
@ -42,6 +43,17 @@ class ElementEditedLogEntry extends AbstractLogEntry
$this->setTargetElement($changed_element);
}
/**
* Sets the old data for this entry.
* @param array $old_data
* @return $this
*/
public function setOldData(array $old_data): self
{
$this->extra['d'] = $old_data;
return $this;
}
/**
* Returns the message associated with this edit change.
*
@ -51,4 +63,20 @@ class ElementEditedLogEntry extends AbstractLogEntry
{
return $this->extra['m'] ?? '';
}
/**
* @inheritDoc
*/
public function hasOldDataInformations(): bool
{
return !empty($this->extra['d']);
}
/**
* @inheritDoc
*/
public function getOldData(): array
{
return $this->extra['d'] ?? [];
}
}

View file

@ -26,6 +26,7 @@ namespace App\Entity\Parts;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\TimestampTrait;
use App\Entity\Contracts\TimeStampableInterface;
use App\Validator\Constraints\Selectable;
use App\Validator\Constraints\ValidPartLot;
use DateTime;
@ -42,7 +43,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* @ORM\HasLifecycleCallbacks()
* @ValidPartLot()
*/
class PartLot extends AbstractDBElement
class PartLot extends AbstractDBElement implements TimeStampableInterface
{
use TimestampTrait;

View file

@ -53,6 +53,7 @@ namespace App\Entity\PriceInformations;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\TimestampTrait;
use App\Entity\Contracts\TimeStampableInterface;
use App\Entity\Parts\Part;
use App\Entity\Parts\Supplier;
use Doctrine\Common\Collections\ArrayCollection;
@ -67,7 +68,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* @ORM\Entity()
* @ORM\HasLifecycleCallbacks()
*/
class Orderdetail extends AbstractDBElement
class Orderdetail extends AbstractDBElement implements TimeStampableInterface
{
use TimestampTrait;

View file

@ -53,6 +53,7 @@ namespace App\Entity\PriceInformations;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\TimestampTrait;
use App\Entity\Contracts\TimeStampableInterface;
use App\Validator\Constraints\Selectable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@ -66,7 +67,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* @ORM\HasLifecycleCallbacks()
* @UniqueEntity(fields={"orderdetail", "min_discount_quantity"})
*/
class Pricedetail extends AbstractDBElement
class Pricedetail extends AbstractDBElement implements TimeStampableInterface
{
use TimestampTrait;

View file

@ -31,15 +31,19 @@ use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\UnitOfWork;
use Doctrine\Persistence\Event\LifecycleEventArgs;
use Symfony\Component\Serializer\SerializerInterface;
class EventLoggerSubscriber implements EventSubscriber
{
protected $logger;
protected $serializer;
public function __construct(EventLogger $logger)
public function __construct(EventLogger $logger, SerializerInterface $serializer)
{
$this->logger = $logger;
$this->serializer = $serializer;
}
public function onFlush(OnFlushEventArgs $eventArgs)
@ -55,6 +59,7 @@ class EventLoggerSubscriber implements EventSubscriber
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if ($this->validEntity($entity)) {
$log = new ElementEditedLogEntry($entity);
$this->saveChangeSet($entity, $log, $uow);
$this->logger->log($log);
}
}
@ -62,6 +67,7 @@ class EventLoggerSubscriber implements EventSubscriber
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if ($this->validEntity($entity)) {
$log = new ElementDeletedLogEntry($entity);
$this->saveChangeSet($entity, $log, $uow);
$this->logger->log($log);
}
}
@ -70,6 +76,18 @@ class EventLoggerSubscriber implements EventSubscriber
$uow->computeChangeSets();
}
protected function saveChangeSet(AbstractDBElement $entity, AbstractLogEntry $logEntry, UnitOfWork $uow): void
{
if (!$logEntry instanceof ElementEditedLogEntry && !$logEntry instanceof ElementDeletedLogEntry) {
throw new \InvalidArgumentException('$logEntry must be ElementEditedLogEntry or ElementDeletedLogEntry!');
}
$changeSet = $uow->getEntityChangeSet($entity);
dump($changeSet);
$old_data = array_diff(array_combine(array_keys($changeSet), array_column($changeSet, 0)), [null]);
$logEntry->setOldData($old_data);
}
public function postPersist(LifecycleEventArgs $args)
{
//Create an log entry

View file

@ -27,6 +27,7 @@ namespace App\Repository;
use App\Entity\Base\AbstractDBElement;
use App\Entity\LogSystem\AbstractLogEntry;
use App\Entity\LogSystem\ElementCreatedLogEntry;
use App\Entity\LogSystem\ElementDeletedLogEntry;
use App\Entity\LogSystem\ElementEditedLogEntry;
use App\Entity\UserSystem\User;
use Doctrine\ORM\EntityRepository;
@ -63,6 +64,48 @@ class LogEntryRepository extends EntityRepository
return $this->findBy(['element' => $element], ['timestamp' => $order], $limit, $offset);
}
public function getTimetravelDataForElement(AbstractDBElement $element, \DateTime $until): array
{
$qb = $this->createQueryBuilder('log');
$qb->select('log')
//->where('log INSTANCE OF App\Entity\LogSystem\ElementEditedLogEntry')
->where('log INSTANCE OF ' . ElementEditedLogEntry::class)
->andWhere('log.target_type = :target_type')
->andWhere('log.target_id = :target_id')
->andWhere('log.timestamp >= :until')
->orderBy('log.timestamp', 'DESC');
$qb->setParameters([
'target_type' => AbstractLogEntry::targetTypeClassToID(get_class($element)),
'target_id' => $element->getID(),
'until' => $until
]);
$query = $qb->getQuery();
return $query->execute();
}
public function getElementExistedAtTimestamp(AbstractDBElement $element, \DateTime $timestamp): bool
{
$qb = $this->createQueryBuilder('log');
$qb->select('count(log)')
->where('log INSTANCE OF ' . ElementCreatedLogEntry::class)
->andWhere('log.target_type = :target_type')
->andWhere('log.target_id = :target_id')
->andWhere('log.timestamp >= :until')
->orderBy('log.timestamp', 'DESC');
$qb->setParameters([
'target_type' => AbstractLogEntry::targetTypeClassToID(get_class($element)),
'target_id' => $element->getID(),
'until' => $timestamp
]);
$query = $qb->getQuery();
$count = $query->getSingleScalarResult();
return !($count > 0);
}
/**
* Gets the last log entries ordered by timestamp.
*

View file

@ -0,0 +1,168 @@
<?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 General Public License
* as published by the Free Software Foundation; either version 2
* 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
*/
namespace App\Services\LogSystem;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Contracts\TimeStampableInterface;
use App\Entity\Contracts\TimeTravelInterface;
use App\Entity\LogSystem\AbstractLogEntry;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Proxies\__CG__\App\Entity\Attachments\AttachmentType;
class TimeTravel
{
protected $em;
protected $repo;
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
$this->repo = $em->getRepository(AbstractLogEntry::class);
}
public function revertEntityToTimestamp(AbstractDBElement $element, \DateTime $timestamp, array $reverted_elements = [])
{
if (!$element instanceof TimeStampableInterface) {
throw new \InvalidArgumentException('$element must have a Timestamp!');
}
if ($timestamp > new \DateTime('now')) {
throw new \InvalidArgumentException('You can not travel to the future (yet)...');
}
//Skip this process if already were reverted...
if (in_array($element, $reverted_elements)) {
return;
}
$reverted_elements[] = $element;
$history = $this->repo->getTimetravelDataForElement($element, $timestamp);
/*
if (!$this->repo->getElementExistedAtTimestamp($element, $timestamp)) {
$element = null;
return;
}*/
foreach ($history as $logEntry) {
$this->applyEntry($element, $logEntry);
}
// Revert any of the associated elements
$metadata = $this->em->getClassMetadata(get_class($element));
$associations = $metadata->getAssociationMappings();
foreach ($associations as $field => $mapping) {
if (
($element instanceof AbstractStructuralDBElement && ($field === 'parts' || $field === 'children'))
|| ($element instanceof AttachmentType && $field === 'attachments')
) {
continue;
}
//Revert many to one association
if (
$mapping['type'] === ClassMetadata::MANY_TO_ONE
|| $mapping['type'] === ClassMetadata::ONE_TO_ONE
) {
$target_element = $this->getField($element, $field);
if ($target_element !== null && $element->getLastModified() > $timestamp) {
$this->revertEntityToTimestamp($target_element, $timestamp, $reverted_elements);
}
} elseif (
($mapping['type'] === ClassMetadata::MANY_TO_MANY
|| $mapping['type'] === ClassMetadata::ONE_TO_MANY)
&& $mapping['isOwningSide'] === false
) {
$target_elements = $this->getField($element, $field);
if ($target_elements === null || count($target_elements) > 10) {
continue;
}
foreach ($target_elements as $target_element) {
if ($target_element !== null && $element->getLastModified() > $timestamp) {
//Remove the element from collection, if it did not existed at $timestamp
if (!$this->repo->getElementExistedAtTimestamp($target_element, $timestamp)) {
if ($target_elements instanceof Collection) {
$target_elements->removeElement($target_element);
}
}
$this->revertEntityToTimestamp($target_element, $timestamp, $reverted_elements);
}
}
}
}
}
public function applyEntry(AbstractDBElement $element, TimeTravelInterface $logEntry): void
{
//Skip if this does not provide any info...
if (!$logEntry->hasOldDataInformations()) {
return;
}
if (!$element instanceof TimeStampableInterface) {
return;
}
$metadata = $this->em->getClassMetadata(get_class($element));
$old_data = $logEntry->getOldData();
foreach ($old_data as $field => $data) {
if ($metadata->hasField($field)) {
$this->setField($element, $field, $data);
}
if ($metadata->hasAssociation($field)) {
$target_class = $metadata->getAssociationMapping($field)['targetEntity'];
$target_id = null;
//Try to extract the old ID:
if (is_array($data) && isset($data['@id'])) {
$target_id = $data['@id'];
} else {
throw new \RuntimeException('The given $logEntry contains invalid informations!');
}
$entity = $this->em->getPartialReference($target_class, $target_id);
$this->setField($element, $field, $entity);
}
}
$this->setField($element, 'lastModified', $logEntry->getTimestamp());
}
protected function getField(AbstractDBElement $element, string $field)
{
$reflection = new \ReflectionClass(get_class($element));
$property = $reflection->getProperty($field);
$property->setAccessible(true);
return $property->getValue($element);
}
protected function setField(AbstractDBElement $element, string $field, $new_value)
{
$reflection = new \ReflectionClass(get_class($element));
$property = $reflection->getProperty($field);
$property->setAccessible(true);
$property->setValue($element, $new_value);
}
}

View file

@ -4,16 +4,28 @@
{% trans %}part.info.title{% endtrans %} {{ part.name }}
{% endblock %}
{% block card_type %}
{% if timeTravel == null %}
bg-primary text-white
{% else %}
bg-primary-striped text-white
{% endif %}
{% endblock %}
{% block card_title %}
<i class="fa {{ part.favorite ? 'fa-star' : 'fa-info-circle'}} fa-fw" aria-hidden="true"></i>
{% trans %}part.info.title{% endtrans %} <b>"{{ part.name }}"</b>
{% if timeTravel != null %}
<i>({{ timeTravel | format_datetime('short') }})</i>
{% endif %}
<div class="float-right">
{% trans %}id.label{% endtrans %}: {{ part.id }}
</div>
{% endblock %}
{% block card_content %}
<div class="row">
<div class="col-md-9">
{% include "Parts/info/_main_infos.html.twig" %}
@ -28,14 +40,14 @@
<div class="">
<div class="">
<ul class="nav nav-tabs" id="partTab" role="tablist">
<li class="nav-item">
<a class="nav-link {% if part.partLots %}active{% endif %}" id="part_lots-tab" data-toggle="tab"
href="#part_lots" role="tab">
<i class="fas fa-box fa-fw"></i>
{% trans %}part.part_lots.label{% endtrans %}
<span class="badge badge-secondary">{{ part.partLots | length }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if part.partLots %}active{% endif %}" id="part_lots-tab" data-toggle="tab"
href="#part_lots" role="tab">
<i class="fas fa-box fa-fw"></i>
{% trans %}part.part_lots.label{% endtrans %}
<span class="badge badge-secondary">{{ part.partLots | length }}</span>
</a>
</li>
{% if part.comment is not empty %}
<li class="nav-item">
<a class="nav-link" id="comment-tab" data-toggle="tab"