diff --git a/assets/css/app.css b/assets/css/app.css index d1dc0c53..0f3b7ef4 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -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; } diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index ee0b7281..f32f2b7c 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -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, ]); } diff --git a/src/Entity/Base/AbstractDBElement.php b/src/Entity/Base/AbstractDBElement.php index b73987c9..b6e80cf1 100644 --- a/src/Entity/Base/AbstractDBElement.php +++ b/src/Entity/Base/AbstractDBElement.php @@ -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()]; + } } diff --git a/src/Entity/Base/AbstractNamedDBElement.php b/src/Entity/Base/AbstractNamedDBElement.php index a34dbe7f..af555f85 100644 --- a/src/Entity/Base/AbstractNamedDBElement.php +++ b/src/Entity/Base/AbstractNamedDBElement.php @@ -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; diff --git a/src/Entity/Contracts/TimeStampableInterface.php b/src/Entity/Contracts/TimeStampableInterface.php new file mode 100644 index 00000000..8b61e05e --- /dev/null +++ b/src/Entity/Contracts/TimeStampableInterface.php @@ -0,0 +1,44 @@ +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'] ?? []; + } } diff --git a/src/Entity/LogSystem/ElementEditedLogEntry.php b/src/Entity/LogSystem/ElementEditedLogEntry.php index 136490fd..c47b0e33 100644 --- a/src/Entity/LogSystem/ElementEditedLogEntry.php +++ b/src/Entity/LogSystem/ElementEditedLogEntry.php @@ -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'] ?? []; + } } diff --git a/src/Entity/Parts/PartLot.php b/src/Entity/Parts/PartLot.php index 24fd233a..c8cf1306 100644 --- a/src/Entity/Parts/PartLot.php +++ b/src/Entity/Parts/PartLot.php @@ -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; diff --git a/src/Entity/PriceInformations/Orderdetail.php b/src/Entity/PriceInformations/Orderdetail.php index 805d5b36..0781e316 100644 --- a/src/Entity/PriceInformations/Orderdetail.php +++ b/src/Entity/PriceInformations/Orderdetail.php @@ -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; diff --git a/src/Entity/PriceInformations/Pricedetail.php b/src/Entity/PriceInformations/Pricedetail.php index 91f5f939..0e98264b 100644 --- a/src/Entity/PriceInformations/Pricedetail.php +++ b/src/Entity/PriceInformations/Pricedetail.php @@ -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; diff --git a/src/EventSubscriber/EventLoggerSubscriber.php b/src/EventSubscriber/EventLoggerSubscriber.php index b7d7c768..490eefa4 100644 --- a/src/EventSubscriber/EventLoggerSubscriber.php +++ b/src/EventSubscriber/EventLoggerSubscriber.php @@ -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 diff --git a/src/Repository/LogEntryRepository.php b/src/Repository/LogEntryRepository.php index 9ff420fa..f60623d6 100644 --- a/src/Repository/LogEntryRepository.php +++ b/src/Repository/LogEntryRepository.php @@ -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. * diff --git a/src/Services/LogSystem/TimeTravel.php b/src/Services/LogSystem/TimeTravel.php new file mode 100644 index 00000000..9b51d2e6 --- /dev/null +++ b/src/Services/LogSystem/TimeTravel.php @@ -0,0 +1,168 @@ +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); + } +} \ No newline at end of file diff --git a/templates/Parts/info/show_part_info.html.twig b/templates/Parts/info/show_part_info.html.twig index 1dbe0e2c..14a243a2 100644 --- a/templates/Parts/info/show_part_info.html.twig +++ b/templates/Parts/info/show_part_info.html.twig @@ -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 %} {% trans %}part.info.title{% endtrans %} "{{ part.name }}" + {% if timeTravel != null %} + ({{ timeTravel | format_datetime('short') }}) + {% endif %}