';
+
+ return $tmp;
+ }
+}
\ No newline at end of file
diff --git a/src/DataTables/LogDataTable.php b/src/DataTables/LogDataTable.php
index 145fbef8..9ae2b8db 100644
--- a/src/DataTables/LogDataTable.php
+++ b/src/DataTables/LogDataTable.php
@@ -42,36 +42,73 @@ 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\DataTables\Column\RevertLogColumn;
+use App\Entity\Base\AbstractDBElement;
+use App\Entity\Contracts\TimeTravelInterface;
use App\Entity\LogSystem\AbstractLogEntry;
+use App\Entity\LogSystem\CollectionElementDeleted;
+use App\Entity\LogSystem\ElementCreatedLogEntry;
+use App\Entity\LogSystem\ElementDeletedLogEntry;
+use App\Entity\LogSystem\ElementEditedLogEntry;
+use App\Entity\UserSystem\Group;
+use App\Entity\UserSystem\User;
+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\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
+use Symfony\Flex\Options;
class LogDataTable implements DataTableTypeInterface
{
protected $elementTypeNameGenerator;
protected $translator;
protected $urlGenerator;
+ protected $entityURLGenerator;
+ protected $logRepo;
+ protected $security;
public function __construct(ElementTypeNameGenerator $elementTypeNameGenerator, TranslatorInterface $translator,
- UrlGeneratorInterface $urlGenerator)
+ UrlGeneratorInterface $urlGenerator, EntityURLGenerator $entityURLGenerator, EntityManagerInterface $entityManager, Security $security)
{
$this->elementTypeNameGenerator = $elementTypeNameGenerator;
$this->translator = $translator;
$this->urlGenerator = $urlGenerator;
+ $this->entityURLGenerator = $entityURLGenerator;
+ $this->logRepo = $entityManager->getRepository(AbstractLogEntry::class);
+ $this->security = $security;
+ }
+
+ public function configureOptions(OptionsResolver $optionsResolver)
+ {
+ $optionsResolver->setDefaults([
+ 'mode' => 'system_log',
+ 'filter_elements' => [],
+ ]);
+
+ $optionsResolver->setAllowedValues('mode', ['system_log', 'element_history', 'last_activity']);
}
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) {
@@ -114,7 +151,11 @@ class LogDataTable implements DataTableTypeInterface
break;
}
- return sprintf('', $symbol);
+ return sprintf(
+ '',
+ $symbol,
+ $context->getLevelString()
+ );
},
]);
@@ -138,6 +179,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 +220,77 @@ 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())
+ || $context instanceof CollectionElementDeleted
+ ) {
+ try {
+ $target = $this->logRepo->getTargetElement($context);
+ if($target !== null) {
+ $str = $this->entityURLGenerator->timeTravelURL($target, $context->getTimestamp());
+ return $str;
+ }
+ } catch (EntityNotSupportedException $exception) {
+ return null;
+ }
+ }
+ return null;
+ },
+ 'disabled' => function ($value, AbstractLogEntry $context) {
+ return
+ !$this->security->isGranted('@tools.timetravel')
+ || !$this->security->isGranted('show_history', $context->getTargetClass());
+ }
+
+ ]);
+
+ $dataTable->add('actionRevert', RevertLogColumn::class, [
+ 'label' => ''
+ ]);
+
$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 ($options['mode'] === 'last_activity') {
+ $builder->where('log INSTANCE OF ' . ElementCreatedLogEntry::class)
+ ->orWhere('log INSTANCE OF ' . ElementDeletedLogEntry::class)
+ ->orWhere('log INSTANCE OF ' . ElementEditedLogEntry::class)
+ ->orWhere('log INSTANCE OF ' . CollectionElementDeleted::class)
+ ->andWhere('log.target_type NOT IN (:disallowed)');;
+
+ $builder->setParameter('disallowed', [
+ AbstractLogEntry::targetTypeClassToID(User::class),
+ AbstractLogEntry::targetTypeClassToID(Group::class),
+ ]);
+ }
+
+ 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");
+ }
+ }
}
}
diff --git a/src/Entity/Base/AbstractDBElement.php b/src/Entity/Base/AbstractDBElement.php
index 02177177..46dac31f 100644
--- a/src/Entity/Base/AbstractDBElement.php
+++ b/src/Entity/Base/AbstractDBElement.php
@@ -34,7 +34,7 @@ use Symfony\Component\Serializer\Annotation\Groups;
* Every database table which are managed with this class (or a subclass of it)
* must have the table row "id"!! The ID is the unique key to identify the elements.
*
- * @ORM\MappedSuperclass()
+ * @ORM\MappedSuperclass(repositoryClass="App\Repository\DBElementRepository")
*
* @ORM\EntityListeners({"App\Security\EntityListeners\ElementPermissionListener"})
*
@@ -55,7 +55,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.
@@ -92,4 +92,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 902d224d..212211f3 100644
--- a/src/Entity/Base/AbstractNamedDBElement.php
+++ b/src/Entity/Base/AbstractNamedDBElement.php
@@ -23,6 +23,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;
@@ -30,10 +31,10 @@ use Symfony\Component\Validator\Constraints as Assert;
/**
* All subclasses of this class have an attribute "name".
*
- * @ORM\MappedSuperclass(repositoryClass="App\Repository\UserRepository")
+ * @ORM\MappedSuperclass(repositoryClass="App\Repository\NamedDBElement")
* @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/LogWithCommentInterface.php b/src/Entity/Contracts/LogWithCommentInterface.php
new file mode 100644
index 00000000..3a423166
--- /dev/null
+++ b/src/Entity/Contracts/LogWithCommentInterface.php
@@ -0,0 +1,45 @@
+.
+ */
+
+namespace App\Entity\Contracts;
+
+
+interface LogWithCommentInterface
+{
+ /**
+ * Checks if this log entry has a user provided comment.
+ * @return bool
+ */
+ public function hasComment(): bool;
+
+ /**
+ * Gets the user provided comment associated with this log entry.
+ * Returns null if not comment was set.
+ * @return string|null
+ */
+ public function getComment(): ?string;
+
+ /**
+ * Sets the user provided comment associated with this log entry.
+ * @param string|null $new_comment
+ * @return $this
+ */
+ public function setComment(?string $new_comment): self;
+}
\ No newline at end of file
diff --git a/src/Entity/Contracts/LogWithEventUndoInterface.php b/src/Entity/Contracts/LogWithEventUndoInterface.php
new file mode 100644
index 00000000..7ce7f611
--- /dev/null
+++ b/src/Entity/Contracts/LogWithEventUndoInterface.php
@@ -0,0 +1,55 @@
+.
+ */
+
+namespace App\Entity\Contracts;
+
+
+use App\Entity\LogSystem\AbstractLogEntry;
+
+interface LogWithEventUndoInterface
+{
+ /**
+ * Checks if this element undoes another event.
+ * @return bool
+ */
+ public function isUndoEvent(): bool;
+
+ /**
+ * Returns the ID of the undone event or null if no event is undone.
+ * @return int|null
+ */
+ public function getUndoEventID(): ?int;
+
+ /**
+ * Sets the event that is undone, and the undo mode.
+ * @param AbstractLogEntry $event
+ * @param string $mode
+ * @return $this
+ */
+ public function setUndoneEvent(AbstractLogEntry $event, string $mode = 'undo'): self;
+
+ /**
+ * Returns the mode how the event was undone:
+ * "undo" = Only a single event was applied to element
+ * "revert" = Element was reverted to the state it was to the timestamp of the log.
+ * @return string
+ */
+ public function getUndoMode(): string;
+}
\ No newline at end of file
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 @@
+target_id = $target_id;
+ return $this;
+ }
+
public function getExtraData(): array
{
return $this->extra;
diff --git a/src/Entity/LogSystem/CollectionElementDeleted.php b/src/Entity/LogSystem/CollectionElementDeleted.php
new file mode 100644
index 00000000..3d127e41
--- /dev/null
+++ b/src/Entity/LogSystem/CollectionElementDeleted.php
@@ -0,0 +1,136 @@
+.
+ */
+
+namespace App\Entity\LogSystem;
+
+
+use App\Entity\Base\AbstractDBElement;
+use App\Entity\Contracts\LogWithEventUndoInterface;
+use App\Entity\Contracts\NamedElementInterface;
+use Doctrine\ORM\Mapping as ORM;
+
+/**
+ * @ORM\Entity()
+ * This log entry is created when an element is deleted, that is used in a collection of an other entity.
+ * This is needed to signal time travel, that it has to undelete the deleted entity.
+ */
+class CollectionElementDeleted extends AbstractLogEntry implements LogWithEventUndoInterface
+{
+ protected $typeString = 'collection_element_deleted';
+ protected $level = self::LEVEL_INFO;
+
+ public function __construct(AbstractDBElement $changed_element, string $collection_name, AbstractDBElement $deletedElement)
+ {
+ parent::__construct();
+
+ $this->level = self::LEVEL_INFO;
+ $this->setTargetElement($changed_element);
+ $this->extra['n'] = $collection_name;
+ $this->extra['c'] = self::targetTypeClassToID(get_class($deletedElement));
+ $this->extra['i'] = $deletedElement->getID();
+ if ($deletedElement instanceof NamedElementInterface) {
+ $this->extra['o'] = $deletedElement->getName();
+ }
+ }
+
+ /**
+ * Get the name of the collection (on target element) that was changed.
+ * @return string
+ */
+ public function getCollectionName(): string
+ {
+ return $this->extra['n'];
+ }
+
+ /**
+ * Gets the name of the element that was deleted.
+ * Return null, if the element did not have a name.
+ * @return string|null
+ */
+ public function getOldName(): ?string
+ {
+ return $this->extra['o'] ?? null;
+ }
+
+ /**
+ * Returns the class of the deleted element.
+ * @return string
+ */
+ public function getDeletedElementClass(): string
+ {
+ return self::targetTypeIdToClass($this->extra['c']);
+ }
+
+ /**
+ * Returns the ID of the deleted element.
+ * @return int
+ */
+ public function getDeletedElementID(): int
+ {
+ return $this->extra['i'];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function isUndoEvent(): bool
+ {
+ return isset($this->extra['u']);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getUndoEventID(): ?int
+ {
+ return $this->extra['u'] ?? null;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function setUndoneEvent(AbstractLogEntry $event, string $mode = 'undo'): LogWithEventUndoInterface
+ {
+ $this->extra['u'] = $event->getID();
+
+ if ($mode === 'undo') {
+ $this->extra['um'] = 1;
+ } elseif ($mode === 'revert') {
+ $this->extra['um'] = 2;
+ } else {
+ throw new \InvalidArgumentException('Passed invalid $mode!');
+ }
+
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getUndoMode(): string
+ {
+ $mode_int = $this->extra['um'] ?? 1;
+ if ($mode_int === 1) {
+ return 'undo';
+ } else {
+ return 'revert';
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Entity/LogSystem/ElementCreatedLogEntry.php b/src/Entity/LogSystem/ElementCreatedLogEntry.php
index f03f2187..cc591db3 100644
--- a/src/Entity/LogSystem/ElementCreatedLogEntry.php
+++ b/src/Entity/LogSystem/ElementCreatedLogEntry.php
@@ -43,12 +43,16 @@ declare(strict_types=1);
namespace App\Entity\LogSystem;
use App\Entity\Base\AbstractDBElement;
+use App\Entity\Contracts\LogWithCommentInterface;
+use App\Entity\Contracts\LogWithEventUndoInterface;
+use App\Entity\UserSystem\Group;
+use App\Entity\UserSystem\User;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity()
*/
-class ElementCreatedLogEntry extends AbstractLogEntry
+class ElementCreatedLogEntry extends AbstractLogEntry implements LogWithCommentInterface, LogWithEventUndoInterface
{
protected $typeString = 'element_created';
@@ -57,6 +61,11 @@ class ElementCreatedLogEntry extends AbstractLogEntry
parent::__construct();
$this->level = self::LEVEL_INFO;
$this->setTargetElement($new_element);
+
+ //Creation of new users is maybe more interesting...
+ if ($new_element instanceof User || $new_element instanceof Group) {
+ $this->level = self::LEVEL_NOTICE;
+ }
}
/**
@@ -78,4 +87,76 @@ class ElementCreatedLogEntry extends AbstractLogEntry
{
return null !== $this->getCreationInstockValue();
}
+
+ /**
+ * @inheritDoc
+ */
+ public function hasComment(): bool
+ {
+ return isset($this->extra['m']);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getComment(): ?string
+ {
+ return $this->extra['m'] ?? null;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function setComment(?string $new_comment): LogWithCommentInterface
+ {
+ $this->extra['m'] = $new_comment;
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function isUndoEvent(): bool
+ {
+ return isset($this->extra['u']);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getUndoEventID(): ?int
+ {
+ return $this->extra['u'] ?? null;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function setUndoneEvent(AbstractLogEntry $event, string $mode = 'undo'): LogWithEventUndoInterface
+ {
+ $this->extra['u'] = $event->getID();
+
+ if ($mode === 'undo') {
+ $this->extra['um'] = 1;
+ } elseif ($mode === 'revert') {
+ $this->extra['um'] = 2;
+ } else {
+ throw new \InvalidArgumentException('Passed invalid $mode!');
+ }
+
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getUndoMode(): string
+ {
+ $mode_int = $this->extra['um'] ?? 1;
+ if ($mode_int === 1) {
+ return 'undo';
+ } else {
+ return 'revert';
+ }
+ }
}
diff --git a/src/Entity/LogSystem/ElementDeletedLogEntry.php b/src/Entity/LogSystem/ElementDeletedLogEntry.php
index 5ff35fe9..c6e6c4f3 100644
--- a/src/Entity/LogSystem/ElementDeletedLogEntry.php
+++ b/src/Entity/LogSystem/ElementDeletedLogEntry.php
@@ -43,13 +43,18 @@ declare(strict_types=1);
namespace App\Entity\LogSystem;
use App\Entity\Base\AbstractDBElement;
+use App\Entity\Contracts\LogWithCommentInterface;
+use App\Entity\Contracts\LogWithEventUndoInterface;
use App\Entity\Contracts\NamedElementInterface;
+use App\Entity\Contracts\TimeTravelInterface;
+use App\Entity\UserSystem\Group;
+use App\Entity\UserSystem\User;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity()
*/
-class ElementDeletedLogEntry extends AbstractLogEntry
+class ElementDeletedLogEntry extends AbstractLogEntry implements TimeTravelInterface, LogWithCommentInterface, LogWithEventUndoInterface
{
protected $typeString = 'element_deleted';
@@ -58,6 +63,11 @@ class ElementDeletedLogEntry extends AbstractLogEntry
parent::__construct();
$this->level = self::LEVEL_INFO;
$this->setTargetElement($deleted_element);
+
+ //Deletion of a user is maybe more interesting...
+ if ($deleted_element instanceof User || $deleted_element instanceof Group) {
+ $this->level = self::LEVEL_NOTICE;
+ }
}
/**
@@ -83,4 +93,102 @@ 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['o']);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getOldData(): array
+ {
+ return $this->extra['o'] ?? [];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function hasComment(): bool
+ {
+ return isset($this->extra['m']);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getComment(): ?string
+ {
+ return $this->extra['m'] ?? null;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function setComment(?string $new_comment): LogWithCommentInterface
+ {
+ $this->extra['m'] = $new_comment;
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function isUndoEvent(): bool
+ {
+ return isset($this->extra['u']);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getUndoEventID(): ?int
+ {
+ return $this->extra['u'] ?? null;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function setUndoneEvent(AbstractLogEntry $event, string $mode = 'undo'): LogWithEventUndoInterface
+ {
+ $this->extra['u'] = $event->getID();
+
+ if ($mode === 'undo') {
+ $this->extra['um'] = 1;
+ } elseif ($mode === 'revert') {
+ $this->extra['um'] = 2;
+ } else {
+ throw new \InvalidArgumentException('Passed invalid $mode!');
+ }
+
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getUndoMode(): string
+ {
+ $mode_int = $this->extra['um'] ?? 1;
+ if ($mode_int === 1) {
+ return 'undo';
+ }
+ return 'revert';
+ }
}
diff --git a/src/Entity/LogSystem/ElementEditedLogEntry.php b/src/Entity/LogSystem/ElementEditedLogEntry.php
index b774a5c8..2d22edc9 100644
--- a/src/Entity/LogSystem/ElementEditedLogEntry.php
+++ b/src/Entity/LogSystem/ElementEditedLogEntry.php
@@ -43,12 +43,15 @@ declare(strict_types=1);
namespace App\Entity\LogSystem;
use App\Entity\Base\AbstractDBElement;
+use App\Entity\Contracts\LogWithCommentInterface;
+use App\Entity\Contracts\LogWithEventUndoInterface;
+use App\Entity\Contracts\TimeTravelInterface;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity()
*/
-class ElementEditedLogEntry extends AbstractLogEntry
+class ElementEditedLogEntry extends AbstractLogEntry implements TimeTravelInterface, LogWithCommentInterface, LogWithEventUndoInterface
{
protected $typeString = 'element_edited';
@@ -61,12 +64,138 @@ class ElementEditedLogEntry extends AbstractLogEntry
}
/**
- * Returns the message associated with this edit change.
- *
- * @return string
+ * Checks if this log contains infos about which fields has changed.
+ * @return bool
*/
- public function getMessage(): string
+ public function hasChangedFieldsInfo(): bool
{
- return $this->extra['m'] ?? '';
+ return isset($this->extra['f']) || $this->hasOldDataInformations();
+ }
+
+ /**
+ * Return the names of all fields that were changed during the change.
+ * @return string[]
+ */
+ public function getChangedFields(): array
+ {
+ if ($this->hasOldDataInformations()) {
+ return array_keys($this->getOldData());
+ }
+
+ if (isset($this->extra['f'])) {
+ return $this->extra['f'];
+ }
+
+ return [];
+ }
+
+ /**
+ * Set the fields that were changed during this element change.
+ * @param string[] $changed_fields The names of the fields that were changed during the elements
+ * @return $this
+ */
+ public function setChangedFields(array $changed_fields): self
+ {
+ $this->extra['f'] = $changed_fields;
+ return $this;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function hasOldDataInformations(): bool
+ {
+ return !empty($this->extra['d']);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getOldData(): array
+ {
+ return $this->extra['d'] ?? [];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function hasComment(): bool
+ {
+ return isset($this->extra['m']);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getComment(): ?string
+ {
+ return $this->extra['m'] ?? null;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function setComment(?string $new_comment): LogWithCommentInterface
+ {
+ $this->extra['m'] = $new_comment;
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function isUndoEvent(): bool
+ {
+ return isset($this->extra['u']);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getUndoEventID(): ?int
+ {
+ return $this->extra['u'] ?? null;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function setUndoneEvent(AbstractLogEntry $event, string $mode = 'undo'): LogWithEventUndoInterface
+ {
+ $this->extra['u'] = $event->getID();
+
+ if ($mode === 'undo') {
+ $this->extra['um'] = 1;
+ } elseif ($mode === 'revert') {
+ $this->extra['um'] = 2;
+ } else {
+ throw new \InvalidArgumentException('Passed invalid $mode!');
+ }
+
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getUndoMode(): string
+ {
+ $mode_int = $this->extra['um'] ?? 1;
+ if ($mode_int === 1) {
+ return 'undo';
+ } else {
+ return 'revert';
+ }
}
}
diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php
index 61c08672..a94fa37f 100644
--- a/src/Entity/Parts/Part.php
+++ b/src/Entity/Parts/Part.php
@@ -85,7 +85,7 @@ class Part extends AttachmentContainingDBElement
/**
* TODO.
*/
- protected $devices;
+ protected $devices = [];
/**
* @ColumnSecurity(type="datetime")
diff --git a/src/Entity/Parts/PartLot.php b/src/Entity/Parts/PartLot.php
index 5f808a9c..ef738bd8 100644
--- a/src/Entity/Parts/PartLot.php
+++ b/src/Entity/Parts/PartLot.php
@@ -44,6 +44,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;
@@ -60,7 +61,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 98ca1591..04bf748c 100644
--- a/src/Entity/PriceInformations/Orderdetail.php
+++ b/src/Entity/PriceInformations/Orderdetail.php
@@ -52,6 +52,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;
@@ -66,7 +67,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 23205b44..d7f19608 100644
--- a/src/Entity/PriceInformations/Pricedetail.php
+++ b/src/Entity/PriceInformations/Pricedetail.php
@@ -52,6 +52,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;
@@ -65,7 +66,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/Entity/UserSystem/PermissionsEmbed.php b/src/Entity/UserSystem/PermissionsEmbed.php
index 99c93427..c6b01782 100644
--- a/src/Entity/UserSystem/PermissionsEmbed.php
+++ b/src/Entity/UserSystem/PermissionsEmbed.php
@@ -481,7 +481,7 @@ class PermissionsEmbed
*/
final protected static function readBitPair($data, int $n): int
{
- Assert::lessThanEq($n, 31, '$n must be smaller than 32, because only a 32bit int is used! Got %s.');
+ //Assert::lessThanEq($n, 31, '$n must be smaller than 32, because only a 32bit int is used! Got %s.');
if (0 !== $n % 2) {
throw new InvalidArgumentException('$n must be dividable by 2, because we address bit pairs here!');
}
@@ -501,7 +501,7 @@ class PermissionsEmbed
*/
final protected static function writeBitPair(int $data, int $n, int $new): int
{
- Assert::lessThanEq($n, 31, '$n must be smaller than 32, because only a 32bit int is used! Got %s.');
+ //Assert::lessThanEq($n, 31, '$n must be smaller than 32, because only a 32bit int is used! Got %s.');
Assert::lessThanEq($new, 3, '$new must be smaller than 3, because a bit pair is written! Got %s.');
Assert::greaterThanEq($new, 0, '$new must not be negative, because a bit pair is written! Got %s.');
diff --git a/src/EventSubscriber/EventLoggerSubscriber.php b/src/EventSubscriber/EventLoggerSubscriber.php
index b3809de3..0053f688 100644
--- a/src/EventSubscriber/EventLoggerSubscriber.php
+++ b/src/EventSubscriber/EventLoggerSubscriber.php
@@ -20,25 +20,71 @@
namespace App\EventSubscriber;
+use App\Entity\Attachments\Attachment;
+use App\Entity\Attachments\AttachmentType;
use App\Entity\Base\AbstractDBElement;
+use App\Entity\Base\AbstractPartsContainingDBElement;
+use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\LogSystem\AbstractLogEntry;
+use App\Entity\LogSystem\CollectionElementDeleted;
use App\Entity\LogSystem\ElementCreatedLogEntry;
use App\Entity\LogSystem\ElementDeletedLogEntry;
use App\Entity\LogSystem\ElementEditedLogEntry;
+use App\Entity\Parts\PartLot;
+use App\Entity\PriceInformations\Orderdetail;
+use App\Entity\PriceInformations\Pricedetail;
+use App\Entity\UserSystem\User;
+use App\Services\LogSystem\EventCommentHelper;
use App\Services\LogSystem\EventLogger;
+use App\Services\LogSystem\EventUndoHelper;
use Doctrine\Common\EventSubscriber;
+use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\Persistence\Event\LifecycleEventArgs;
+use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
+use Symfony\Component\Serializer\SerializerInterface;
class EventLoggerSubscriber implements EventSubscriber
{
- protected $logger;
+ /** @var array The given fields will not be saved, because they contain sensitive informations */
+ protected const FIELD_BLACKLIST = [
+ User::class => ['password', 'need_pw_change', 'googleAuthenticatorSecret', 'backupCodes', 'trustedDeviceCookieVersion', 'pw_reset_token', 'backupCodesGenerationDate'],
+ ];
- public function __construct(EventLogger $logger)
+ /** @var array If elements of the given class are deleted, a log for the given fields will be triggered */
+ protected const TRIGGER_ASSOCIATION_LOG_WHITELIST = [
+ PartLot::class => ['part'],
+ Orderdetail::class => ['part'],
+ Pricedetail::class => ['orderdetail'],
+ Attachment::class => ['element'],
+ ];
+
+ protected const MAX_STRING_LENGTH = 2000;
+
+ protected $logger;
+ protected $serializer;
+ protected $eventCommentHelper;
+ protected $eventUndoHelper;
+ protected $save_changed_fields;
+ protected $save_changed_data;
+ protected $save_removed_data;
+ protected $propertyAccessor;
+
+ public function __construct(EventLogger $logger, SerializerInterface $serializer, EventCommentHelper $commentHelper,
+ bool $save_changed_fields, bool $save_changed_data, bool $save_removed_data, PropertyAccessorInterface $propertyAccessor,
+ EventUndoHelper $eventUndoHelper)
{
$this->logger = $logger;
+ $this->serializer = $serializer;
+ $this->eventCommentHelper = $commentHelper;
+ $this->propertyAccessor = $propertyAccessor;
+ $this->eventUndoHelper = $eventUndoHelper;
+
+ $this->save_changed_fields = $save_changed_fields;
+ $this->save_changed_data = $save_changed_data;
+ $this->save_removed_data = $save_removed_data;
}
public function onFlush(OnFlushEventArgs $eventArgs)
@@ -53,30 +99,43 @@ class EventLoggerSubscriber implements EventSubscriber
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if ($this->validEntity($entity)) {
- $log = new ElementEditedLogEntry($entity);
- $this->logger->log($log);
+ $this->logElementEdited($entity, $em);
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if ($this->validEntity($entity)) {
- $log = new ElementDeletedLogEntry($entity);
- $this->logger->log($log);
+ $this->logElementDeleted($entity, $em);
}
}
-
$uow->computeChangeSets();
}
public function postPersist(LifecycleEventArgs $args)
{
- //Create an log entry
+ //Create an log entry, we have to do this post persist, cause we have to know the ID
/** @var AbstractDBElement $entity */
$entity = $args->getObject();
if ($this->validEntity($entity)) {
$log = new ElementCreatedLogEntry($entity);
+ //Add user comment to log entry
+ if ($this->eventCommentHelper->isMessageSet()) {
+ $log->setComment($this->eventCommentHelper->getMessage());
+ }
+ if ($this->eventUndoHelper->isUndo()) {
+ $undoEvent = $this->eventUndoHelper->getUndoneEvent();
+
+ $log->setUndoneEvent($undoEvent, $this->eventUndoHelper->getMode());
+
+ if($undoEvent instanceof ElementDeletedLogEntry && $undoEvent->getTargetClass() === $log->getTargetClass()) {
+ $log->setTargetElementID($undoEvent->getTargetID());
+ }
+ if($undoEvent instanceof CollectionElementDeleted && $undoEvent->getDeletedElementClass() === $log->getTargetClass()) {
+ $log->setTargetElementID($undoEvent->getDeletedElementID());
+ }
+ }
$this->logger->log($log);
}
}
@@ -89,6 +148,158 @@ class EventLoggerSubscriber implements EventSubscriber
if ($uow->hasPendingInsertions()) {
$em->flush();
}
+
+ //Clear the message provided by user.
+ $this->eventCommentHelper->clearMessage();
+ $this->eventUndoHelper->clearUndoneEvent();
+ }
+
+ protected function logElementDeleted(AbstractDBElement $entity, EntityManagerInterface $em): void
+ {
+ $log = new ElementDeletedLogEntry($entity);
+ //Add user comment to log entry
+ if ($this->eventCommentHelper->isMessageSet()) {
+ $log->setComment($this->eventCommentHelper->getMessage());
+ }
+ if ($this->eventUndoHelper->isUndo()) {
+ $log->setUndoneEvent($this->eventUndoHelper->getUndoneEvent(), $this->eventUndoHelper->getMode());
+ }
+ if ($this->save_removed_data) {
+ //The 4th param is important here, as we delete the element...
+ $this->saveChangeSet($entity, $log, $em, true);
+ }
+ $this->logger->log($log);
+
+ //Check if we have to log CollectionElementDeleted entries
+ if ($this->save_changed_data) {
+ $metadata = $em->getClassMetadata(get_class($entity));
+ $mappings = $metadata->getAssociationMappings();
+ //Check if class is whitelisted for CollectionElementDeleted entry
+ foreach (static::TRIGGER_ASSOCIATION_LOG_WHITELIST as $class => $whitelist) {
+ if (is_a($entity, $class)) {
+ //Check names
+ foreach ($mappings as $field => $mapping) {
+ if (in_array($field, $whitelist)) {
+ $changed = $this->propertyAccessor->getValue($entity, $field);
+ $log = new CollectionElementDeleted($changed, $mapping['inversedBy'], $entity);
+ if ($this->eventUndoHelper->isUndo()) {
+ $log->setUndoneEvent($this->eventUndoHelper->getUndoneEvent(), $this->eventUndoHelper->getMode());
+ }
+ $this->logger->log($log);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ protected function logElementEdited(AbstractDBElement $entity, EntityManagerInterface $em): void
+ {
+ $uow = $em->getUnitOfWork();
+
+ $log = new ElementEditedLogEntry($entity);
+ if ($this->save_changed_data) {
+ $this->saveChangeSet($entity, $log, $em);
+ } elseif ($this->save_changed_fields) {
+ $changed_fields = array_keys($uow->getEntityChangeSet($entity));
+ //Remove lastModified field, as this is always changed (gives us no additional info)
+ $changed_fields = array_diff($changed_fields, ['lastModified']);
+ $log->setChangedFields($changed_fields);
+ }
+ //Add user comment to log entry
+ if ($this->eventCommentHelper->isMessageSet()) {
+ $log->setComment($this->eventCommentHelper->getMessage());
+ }
+ if ($this->eventUndoHelper->isUndo()) {
+ $log->setUndoneEvent($this->eventUndoHelper->getUndoneEvent(), $this->eventUndoHelper->getMode());
+ }
+ $this->logger->log($log);
+ }
+
+ /**
+ * Check if the given element class has restrictions to its fields
+ * @param AbstractDBElement $element
+ * @return bool True if there are restrictions, and further checking is needed
+ */
+ public function hasFieldRestrictions(AbstractDBElement $element): bool
+ {
+ foreach (static::FIELD_BLACKLIST as $class => $blacklist) {
+ if (is_a($element, $class)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Filter out every forbidden field and return the cleaned array.
+ * @param AbstractDBElement $element
+ * @param array $fields
+ * @return array
+ */
+ protected function filterFieldRestrictions(AbstractDBElement $element, array $fields): array
+ {
+ unset($fields['lastModified']);
+
+ if (!$this->hasFieldRestrictions($element)) {
+ return $fields;
+ }
+
+ return array_filter($fields, function ($value, $key) use ($element) {
+ //Associative array (save changed data) case
+ if (is_string($key)) {
+ return $this->shouldFieldBeSaved($element, $key);
+ }
+
+ return $this->shouldFieldBeSaved($element, $value);
+ }, ARRAY_FILTER_USE_BOTH);
+ }
+
+ /**
+ * Checks if the field of the given element should be saved (if it is not blacklisted).
+ * @param AbstractDBElement $element
+ * @param string $field_name
+ * @return bool
+ */
+ public function shouldFieldBeSaved(AbstractDBElement $element, string $field_name): bool
+ {
+ foreach (static::FIELD_BLACKLIST as $class => $blacklist) {
+ if (is_a($element, $class) && in_array($field_name, $blacklist)) {
+ return false;
+ }
+ }
+
+ //By default allow every field.
+ return true;
+ }
+
+ protected function saveChangeSet(AbstractDBElement $entity, AbstractLogEntry $logEntry, EntityManagerInterface $em, $element_deleted = false): void
+ {
+ $uow = $em->getUnitOfWork();
+
+ if (!$logEntry instanceof ElementEditedLogEntry && !$logEntry instanceof ElementDeletedLogEntry) {
+ throw new \InvalidArgumentException('$logEntry must be ElementEditedLogEntry or ElementDeletedLogEntry!');
+ }
+
+ if ($element_deleted) { //If the element was deleted we can use getOriginalData to save its content
+ $old_data = $uow->getOriginalEntityData($entity);
+ } else { //Otherwise we have to get it from entity changeset
+ $changeSet = $uow->getEntityChangeSet($entity);
+ $old_data = array_combine(array_keys($changeSet), array_column($changeSet, 0));
+ }
+ $old_data = $this->filterFieldRestrictions($entity, $old_data);
+
+ //Restrict length of string fields, to save memory...
+ $old_data = array_map(function ($value) {
+ if (is_string($value)) {
+ return mb_strimwidth($value, 0, self::MAX_STRING_LENGTH, '...');
+ }
+
+ return $value;
+ }, $old_data);
+
+ $logEntry->setOldData($old_data);
}
/**
diff --git a/src/Form/AdminPages/BaseEntityAdminForm.php b/src/Form/AdminPages/BaseEntityAdminForm.php
index 6930b02e..da9dfcfe 100644
--- a/src/Form/AdminPages/BaseEntityAdminForm.php
+++ b/src/Form/AdminPages/BaseEntityAdminForm.php
@@ -144,6 +144,13 @@ class BaseEntityAdminForm extends AbstractType
'entity' => $entity,
]);
+ $builder->add('log_comment', TextType::class, [
+ 'label' => 'edit.log_comment',
+ 'mapped' => false,
+ 'required' => false,
+ 'empty_data' => null,
+ ]);
+
//Buttons
$builder->add('save', SubmitType::class, [
'label' => $is_new ? 'entity.create' : 'entity.edit.save',
diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php
index 0390bbad..cf30f988 100644
--- a/src/Form/Part/PartBaseType.php
+++ b/src/Form/Part/PartBaseType.php
@@ -264,6 +264,13 @@ class PartBaseType extends AbstractType
],
]);
+ $builder->add('log_comment', TextType::class, [
+ 'label' => 'edit.log_comment',
+ 'mapped' => false,
+ 'required' => false,
+ 'empty_data' => null,
+ ]);
+
$builder
//Buttons
->add('save', SubmitType::class, ['label' => 'part.edit.save'])
diff --git a/src/Form/UserAdminForm.php b/src/Form/UserAdminForm.php
index a4af2f81..ec356710 100644
--- a/src/Form/UserAdminForm.php
+++ b/src/Form/UserAdminForm.php
@@ -250,6 +250,13 @@ class UserAdminForm extends AbstractType
'disabled' => ! $this->security->isGranted($is_new ? 'create' : 'edit', $entity),
]);
+ $builder->add('log_comment', TextType::class, [
+ 'label' => 'edit.log_comment',
+ 'mapped' => false,
+ 'required' => false,
+ 'empty_data' => null,
+ ]);
+
//Buttons
$builder->add('save', SubmitType::class, [
'label' => $is_new ? 'user.create' : 'user.edit.save',
diff --git a/src/Repository/AttachmentRepository.php b/src/Repository/AttachmentRepository.php
index f2c6c774..f8b78e41 100644
--- a/src/Repository/AttachmentRepository.php
+++ b/src/Repository/AttachmentRepository.php
@@ -23,7 +23,7 @@ namespace App\Repository;
use Doctrine\ORM\EntityRepository;
-class AttachmentRepository extends EntityRepository
+class AttachmentRepository extends DBElementRepository
{
/**
* Gets the count of all private/secure attachments.
diff --git a/src/Repository/DBElementRepository.php b/src/Repository/DBElementRepository.php
new file mode 100644
index 00000000..94b19ec2
--- /dev/null
+++ b/src/Repository/DBElementRepository.php
@@ -0,0 +1,56 @@
+.
+ */
+
+namespace App\Repository;
+
+
+use App\Entity\Base\AbstractDBElement;
+use Doctrine\ORM\EntityRepository;
+
+class DBElementRepository extends EntityRepository
+{
+ /**
+ * Changes the ID of the given element to a new value.
+ * You should only use it to undelete former existing elements, everything else is most likely a bad idea!
+ * @param AbstractDBElement $element The element whose ID should be changed
+ * @param int $new_id The new ID
+ */
+ public function changeID(AbstractDBElement $element, int $new_id): void
+ {
+ $qb = $this->createQueryBuilder('element');
+ $q = $qb->update(get_class($element), 'element')
+ ->set('element.id', $new_id)
+ ->where('element.id = ?1')
+ ->setParameter(1, $element->getID())
+ ->getQuery();
+
+ $p = $q->execute();
+
+ $this->setField($element, 'id', $new_id);
+ }
+
+ 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/src/Repository/LogEntryRepository.php b/src/Repository/LogEntryRepository.php
index ae571f80..53c9b4d9 100644
--- a/src/Repository/LogEntryRepository.php
+++ b/src/Repository/LogEntryRepository.php
@@ -44,13 +44,15 @@ namespace App\Repository;
use App\Entity\Base\AbstractDBElement;
use App\Entity\LogSystem\AbstractLogEntry;
+use App\Entity\LogSystem\CollectionElementDeleted;
use App\Entity\LogSystem\ElementCreatedLogEntry;
+use App\Entity\LogSystem\ElementDeletedLogEntry;
use App\Entity\LogSystem\ElementEditedLogEntry;
use App\Entity\UserSystem\User;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
-class LogEntryRepository extends EntityRepository
+class LogEntryRepository extends DBElementRepository
{
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null)
{
@@ -81,6 +83,93 @@ class LogEntryRepository extends EntityRepository
return $this->findBy(['element' => $element], ['timestamp' => $order], $limit, $offset);
}
+ /**
+ * Try to get a log entry that contains the information to undete a given element
+ * @param string $class The class of the element that should be undeleted
+ * @param int $id The ID of the element that should be deleted
+ * @return ElementDeletedLogEntry
+ */
+ public function getUndeleteDataForElement(string $class, int $id): ElementDeletedLogEntry
+ {
+ $qb = $this->createQueryBuilder('log');
+ $qb->select('log')
+ //->where('log INSTANCE OF App\Entity\LogSystem\ElementEditedLogEntry')
+ ->where('log INSTANCE OF ' . ElementDeletedLogEntry::class)
+ ->andWhere('log.target_type = :target_type')
+ ->andWhere('log.target_id = :target_id')
+ ->orderBy('log.timestamp', 'DESC')
+ ->setMaxResults(1);
+
+ $qb->setParameters([
+ 'target_type' => AbstractLogEntry::targetTypeClassToID($class),
+ 'target_id' => $id,
+ ]);
+
+ $query = $qb->getQuery();
+
+ $results = $query->execute();
+
+ if (empty($results)) {
+ throw new \RuntimeException("No undelete data could be found for this element");
+ }
+ return $results[0];
+ }
+
+ /**
+ * Gets all log entries that are related to time travelling
+ * @param AbstractDBElement $element The element for which the time travel data should be retrieved
+ * @param \DateTime $until Back to which timestamp should the data be get (including the timestamp)
+ * @return AbstractLogEntry[]
+ */
+ 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)
+ ->orWhere('log INSTANCE OF ' . CollectionElementDeleted::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();
+ }
+
+ /**
+ * Check if the given element has existed at the given timestamp
+ * @param AbstractDBElement $element
+ * @param \DateTime $timestamp
+ * @return bool True if the element existed at the given timestamp
+ */
+ 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/Repository/NamedDBElementRepository.php b/src/Repository/NamedDBElementRepository.php
index 8fa4ecc1..404efed0 100644
--- a/src/Repository/NamedDBElementRepository.php
+++ b/src/Repository/NamedDBElementRepository.php
@@ -46,7 +46,7 @@ use App\Entity\Base\AbstractNamedDBElement;
use App\Helpers\Trees\TreeViewNode;
use Doctrine\ORM\EntityRepository;
-class NamedDBElementRepository extends EntityRepository
+class NamedDBElementRepository extends DBElementRepository
{
/**
* Gets a tree of TreeViewNode elements. The root elements has $parent as parent.
diff --git a/src/Repository/PartRepository.php b/src/Repository/PartRepository.php
index bd069632..0ac94932 100644
--- a/src/Repository/PartRepository.php
+++ b/src/Repository/PartRepository.php
@@ -46,7 +46,7 @@ use App\Entity\Parts\PartLot;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
-class PartRepository extends EntityRepository
+class PartRepository extends NamedDBElementRepository
{
/**
* Gets the summed up instock of all parts (only parts without an measurent unit)
diff --git a/src/Security/Voter/AttachmentVoter.php b/src/Security/Voter/AttachmentVoter.php
index 01727806..07cc9c25 100644
--- a/src/Security/Voter/AttachmentVoter.php
+++ b/src/Security/Voter/AttachmentVoter.php
@@ -58,11 +58,7 @@ class AttachmentVoter extends ExtendedVoter
*/
protected function voteOnUser($attribute, $subject, User $user): bool
{
- if ($subject instanceof Attachment) {
- return $this->resolver->inherit($user, 'parts_attachments', $attribute) ?? false;
- }
-
- return false;
+ return $this->resolver->inherit($user, 'parts_attachments', $attribute) ?? false;
}
/**
@@ -75,10 +71,11 @@ class AttachmentVoter extends ExtendedVoter
*/
protected function supports($attribute, $subject)
{
- if ($subject instanceof Attachment) {
+ if (is_a($subject, Attachment::class, true)) {
return in_array($attribute, $this->resolver->listOperationsForPermission('parts_attachments'), false);
}
+ //Allow class name as subject
return false;
}
}
diff --git a/src/Security/Voter/GroupVoter.php b/src/Security/Voter/GroupVoter.php
index 0b88b8a0..0201cbe5 100644
--- a/src/Security/Voter/GroupVoter.php
+++ b/src/Security/Voter/GroupVoter.php
@@ -57,11 +57,7 @@ class GroupVoter extends ExtendedVoter
*/
protected function voteOnUser($attribute, $subject, User $user): bool
{
- if ($subject instanceof Group) {
- return $this->resolver->inherit($user, 'groups', $attribute) ?? false;
- }
-
- return false;
+ return $this->resolver->inherit($user, 'groups', $attribute) ?? false;
}
/**
@@ -74,7 +70,7 @@ class GroupVoter extends ExtendedVoter
*/
protected function supports($attribute, $subject)
{
- if ($subject instanceof Group) {
+ if (is_a($subject, Group::class, true)) {
return $this->resolver->isValidOperation('groups', $attribute);
}
diff --git a/src/Security/Voter/LogEntryVoter.php b/src/Security/Voter/LogEntryVoter.php
index cb1c54a1..24fe857a 100644
--- a/src/Security/Voter/LogEntryVoter.php
+++ b/src/Security/Voter/LogEntryVoter.php
@@ -51,22 +51,20 @@ class LogEntryVoter extends ExtendedVoter
protected function voteOnUser($attribute, $subject, User $user): bool
{
- if ($subject instanceof AbstractLogEntry) {
- if ('delete' === $attribute) {
- return $this->resolver->inherit($user, 'system', 'delete_logs') ?? false;
+ if ('delete' === $attribute) {
+ return $this->resolver->inherit($user, 'system', 'delete_logs') ?? false;
+ }
+
+ if ('read' === $attribute) {
+ //Allow read of the users own log entries
+ if (
+ $subject->getUser() === $user
+ && $this->resolver->inherit($user, 'self', 'show_logs')
+ ) {
+ return true;
}
- if ('read' === $attribute) {
- //Allow read of the users own log entries
- if (
- $subject->getUser() === $user
- && $this->resolver->inherit($user, 'self', 'show_logs')
- ) {
- return true;
- }
-
- return $this->resolver->inherit($user, 'system', 'show_logs') ?? false;
- }
+ return $this->resolver->inherit($user, 'system', 'show_logs') ?? false;
}
return false;
diff --git a/src/Security/Voter/OrderdetailVoter.php b/src/Security/Voter/OrderdetailVoter.php
new file mode 100644
index 00000000..f214685e
--- /dev/null
+++ b/src/Security/Voter/OrderdetailVoter.php
@@ -0,0 +1,59 @@
+.
+ */
+
+namespace App\Security\Voter;
+
+
+use App\Entity\Parts\PartLot;
+use App\Entity\PriceInformations\Orderdetail;
+use App\Entity\UserSystem\User;
+
+class OrderdetailVoter extends ExtendedVoter
+{
+ /** @var string[] When this permsission are encountered, they are checked on part */
+ protected const PART_PERMS = ['show_history', 'revert_element'];
+
+ /**
+ * @inheritDoc
+ */
+ protected function voteOnUser($attribute, $subject, User $user): bool
+ {
+ if (in_array($attribute, self::PART_PERMS, true)) {
+ return $this->resolver->inherit($user, 'parts', $attribute) ?? false;
+ }
+
+ return $this->resolver->inherit($user, 'parts_orderdetails', $attribute) ?? false;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function supports($attribute, $subject)
+ {
+ if (is_a($subject, Orderdetail::class, true)) {
+ return in_array($attribute, array_merge(
+ self::PART_PERMS,
+ $this->resolver->listOperationsForPermission('parts_orderdetails')
+ ), true);
+ }
+
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/src/Security/Voter/PartLotVoter.php b/src/Security/Voter/PartLotVoter.php
new file mode 100644
index 00000000..cf022a73
--- /dev/null
+++ b/src/Security/Voter/PartLotVoter.php
@@ -0,0 +1,58 @@
+.
+ */
+
+namespace App\Security\Voter;
+
+
+use App\Entity\Parts\PartLot;
+use App\Entity\UserSystem\User;
+
+class PartLotVoter extends ExtendedVoter
+{
+ /** @var string[] When this permsission are encountered, they are checked on part */
+ protected const PART_PERMS = ['show_history', 'revert_element'];
+
+ /**
+ * @inheritDoc
+ */
+ protected function voteOnUser($attribute, $subject, User $user): bool
+ {
+ if (in_array($attribute, self::PART_PERMS, true)) {
+ return $this->resolver->inherit($user, 'parts', $attribute) ?? false;
+ }
+
+ return $this->resolver->inherit($user, 'parts_lots', $attribute) ?? false;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function supports($attribute, $subject)
+ {
+ if (is_a($subject, PartLot::class, true)) {
+ return in_array($attribute, array_merge(
+ self::PART_PERMS,
+ $this->resolver->listOperationsForPermission('parts_lots')
+ ), true);
+ }
+
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/src/Security/Voter/PartVoter.php b/src/Security/Voter/PartVoter.php
index d7d375cc..f829f557 100644
--- a/src/Security/Voter/PartVoter.php
+++ b/src/Security/Voter/PartVoter.php
@@ -57,11 +57,7 @@ class PartVoter extends ExtendedVoter
protected function supports($attribute, $subject)
{
- // replace with your own logic
- // https://symfony.com/doc/current/security/voters.html
- //return ($subject instanceof Part || in_array($subject, ['PERM_parts', 'PERM_parts_name']));
-
- if ($subject instanceof Part) {
+ if (is_a($subject, Part::class, true)) {
//Check if a sub permission should be checked -> $attribute has format name.edit
if (false !== strpos($attribute, '.')) {
[$perm, $op] = explode('.', $attribute);
@@ -72,24 +68,21 @@ class PartVoter extends ExtendedVoter
return $this->resolver->isValidOperation('parts', $attribute);
}
+ //Allow class name as subject
return false;
}
protected function voteOnUser($attribute, $subject, User $user): bool
{
- if ($subject instanceof Part) {
- //Check for sub permissions
- if (false !== strpos($attribute, '.')) {
- [$perm, $op] = explode('.', $attribute);
+ //Check for sub permissions
+ if (false !== strpos($attribute, '.')) {
+ [$perm, $op] = explode('.', $attribute);
- return $this->resolver->inherit($user, 'parts_'.$perm, $op) ?? false;
- }
-
- //Null concealing operator means, that no
- return $this->resolver->inherit($user, 'parts', $attribute) ?? false;
+ return $this->resolver->inherit($user, 'parts_'.$perm, $op) ?? false;
}
- //Deny access by default.
- return false;
+ //Null concealing operator means, that no
+ return $this->resolver->inherit($user, 'parts', $attribute) ?? false;
+
}
}
diff --git a/src/Security/Voter/PricedetailVoter.php b/src/Security/Voter/PricedetailVoter.php
new file mode 100644
index 00000000..e4ec0251
--- /dev/null
+++ b/src/Security/Voter/PricedetailVoter.php
@@ -0,0 +1,59 @@
+.
+ */
+
+namespace App\Security\Voter;
+
+
+use App\Entity\Parts\PartLot;
+use App\Entity\PriceInformations\Pricedetail;
+use App\Entity\UserSystem\User;
+
+class PricedetailVoter extends ExtendedVoter
+{
+ /** @var string[] When this permsission are encountered, they are checked on part */
+ protected const PART_PERMS = ['show_history', 'revert_element'];
+
+ /**
+ * @inheritDoc
+ */
+ protected function voteOnUser($attribute, $subject, User $user): bool
+ {
+ if (in_array($attribute, self::PART_PERMS, true)) {
+ return $this->resolver->inherit($user, 'parts', $attribute) ?? false;
+ }
+
+ return $this->resolver->inherit($user, 'parts_prices', $attribute) ?? false;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function supports($attribute, $subject)
+ {
+ if (is_a($subject, Pricedetail::class, true)) {
+ return in_array($attribute, array_merge(
+ self::PART_PERMS,
+ $this->resolver->listOperationsForPermission('parts_prices')
+ ), true);
+ }
+
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/src/Security/Voter/StructureVoter.php b/src/Security/Voter/StructureVoter.php
index e2699b48..d5d6eb51 100644
--- a/src/Security/Voter/StructureVoter.php
+++ b/src/Security/Voter/StructureVoter.php
@@ -43,11 +43,13 @@ declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Attachments\AttachmentType;
+use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Devices\Device;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
+use App\Entity\Parts\Part;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
@@ -67,24 +69,29 @@ class StructureVoter extends ExtendedVoter
*/
protected function supports($attribute, $subject)
{
- if (is_object($subject)) {
+ if (is_object($subject) || is_string($subject)) {
$permission_name = $this->instanceToPermissionName($subject);
//If permission name is null, then the subject is not supported
return (null !== $permission_name) && $this->resolver->isValidOperation($permission_name, $attribute);
}
+
return false;
}
/**
* Maps a instance type to the permission name.
*
- * @param mixed $subject The subject for which the permission name should be generated
+ * @param object|string $subject The subject for which the permission name should be generated
*
* @return string|null the name of the permission for the subject's type or null, if the subject is not supported
*/
protected function instanceToPermissionName($subject): ?string
{
- $class_name = get_class($subject);
+ if (!is_string($subject)) {
+ $class_name = get_class($subject);
+ } else {
+ $class_name = $subject;
+ }
switch ($class_name) {
case AttachmentType::class:
return 'attachment_types';
diff --git a/src/Security/Voter/UserVoter.php b/src/Security/Voter/UserVoter.php
index d724a2e6..de7d165b 100644
--- a/src/Security/Voter/UserVoter.php
+++ b/src/Security/Voter/UserVoter.php
@@ -57,11 +57,11 @@ class UserVoter extends ExtendedVoter
*/
protected function supports($attribute, $subject)
{
- if ($subject instanceof User) {
+ if (is_a($subject, User::class, true)) {
return in_array($attribute, array_merge(
$this->resolver->listOperationsForPermission('users'),
$this->resolver->listOperationsForPermission('self')),
- false
+ false
);
}
@@ -89,10 +89,11 @@ class UserVoter extends ExtendedVoter
return $tmp;
}
}
- //Else just check users permission:
- if ($this->resolver->isValidOperation('users', $attribute)) {
- return $this->resolver->inherit($user, 'users', $attribute) ?? false;
- }
+ }
+
+ //Else just check users permission:
+ if ($this->resolver->isValidOperation('users', $attribute)) {
+ return $this->resolver->inherit($user, 'users', $attribute) ?? false;
}
return false;
diff --git a/src/Services/EntityExporter.php b/src/Services/EntityExporter.php
index 5f06e2ca..d55e09ba 100644
--- a/src/Services/EntityExporter.php
+++ b/src/Services/EntityExporter.php
@@ -43,6 +43,14 @@ declare(strict_types=1);
namespace App\Services;
use App\Entity\Base\AbstractNamedDBElement;
+use Symfony\Component\Serializer\Encoder\CsvEncoder;
+use Symfony\Component\Serializer\Encoder\JsonEncoder;
+use Symfony\Component\Serializer\Encoder\XmlEncoder;
+use Symfony\Component\Serializer\Encoder\YamlEncoder;
+use Symfony\Component\Serializer\Normalizer\DataUriNormalizer;
+use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
+use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
+use Symfony\Component\Serializer\Serializer;
use function in_array;
use InvalidArgumentException;
use function is_array;
@@ -62,6 +70,10 @@ class EntityExporter
public function __construct(SerializerInterface $serializer)
{
+ /*$encoders = [new XmlEncoder(), new JsonEncoder(), new CSVEncoder(), new YamlEncoder()];
+ $normalizers = [new ObjectNormalizer(), new DateTimeNormalizer()];
+ $this->serializer = new Serializer($normalizers, $encoders);
+ $this->serializer-> */
$this->serializer = $serializer;
}
@@ -120,13 +132,15 @@ class EntityExporter
$entity_array = [$entity];
}
- $response = new Response($this->serializer->serialize($entity_array, $format,
- [
- 'groups' => $groups,
- 'as_collection' => true,
- 'csv_delimiter' => ';', //Better for Excel
- 'xml_root_node_name' => 'PartDBExport',
- ]));
+ $serialized_data = $this->serializer->serialize($entity_array, $format,
+ [
+ 'groups' => $groups,
+ 'as_collection' => true,
+ 'csv_delimiter' => ';', //Better for Excel
+ 'xml_root_node_name' => 'PartDBExport',
+ ]);
+
+ $response = new Response($serialized_data);
$response->headers->set('Content-Type', $content_type);
@@ -151,7 +165,8 @@ class EntityExporter
// Create the disposition of the file
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
- $filename
+ $filename,
+ $string = preg_replace('![^'.preg_quote('-').'a-z0-_9\s]+!', '', strtolower($filename))
);
// Set the content disposition
$response->headers->set('Content-Disposition', $disposition);
diff --git a/src/Services/EntityURLGenerator.php b/src/Services/EntityURLGenerator.php
index 879afc20..28d691e6 100644
--- a/src/Services/EntityURLGenerator.php
+++ b/src/Services/EntityURLGenerator.php
@@ -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,70 @@ class EntityURLGenerator
throw new InvalidArgumentException('Method is not supported!');
}
+ /**
+ * Gets the URL to view the given element at a given timestamp
+ * @param AbstractDBElement $entity
+ * @param \DateTime $dateTime
+ * @return string
+ */
+ public function timeTravelURL(AbstractDBElement $entity, \DateTime $dateTime): string
+ {
+ $map = [
+ Part::class => 'part_info',
+
+ //As long we does not have own things for it use edit page
+ AttachmentType::class => 'attachment_type_edit',
+ Category::class => 'category_edit',
+ Device::class => 'device_edit',
+ Supplier::class => 'supplier_edit',
+ Manufacturer::class => 'manufacturer_edit',
+ Storelocation::class => 'store_location_edit',
+ Footprint::class => 'footprint_edit',
+ User::class => 'user_edit',
+ Currency::class => 'currency_edit',
+ MeasurementUnit::class => 'measurement_unit_edit',
+ Group::class => 'group_edit',
+ ];
+
+ try {
+ return $this->urlGenerator->generate(
+ $this->mapToController($map, $entity),
+ [
+ 'id' => $entity->getID(),
+ 'timestamp' => $dateTime->getTimestamp()
+ ]
+ );
+ } catch (EntityNotSupportedException $exception) {
+ 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) {
diff --git a/src/Services/LogSystem/EventCommentHelper.php b/src/Services/LogSystem/EventCommentHelper.php
new file mode 100644
index 00000000..6a7b25fc
--- /dev/null
+++ b/src/Services/LogSystem/EventCommentHelper.php
@@ -0,0 +1,72 @@
+.
+ */
+
+namespace App\Services\LogSystem;
+
+
+class EventCommentHelper
+{
+ protected const MAX_MESSAGE_LENGTH = 255;
+
+ protected $message;
+
+ public function __construct()
+ {
+ $message = null;
+ }
+
+ /**
+ * Set the message that will be saved for all ElementEdited/Created/Deleted messages during the next flush.
+ * Set to null if no message should be shown.
+ * After the flush this message is cleared.
+ * @param string|null $message
+ */
+ public function setMessage(?string $message): void
+ {
+ //Restrict the length of the string
+ $this->message = mb_strimwidth($message, 0, self::MAX_MESSAGE_LENGTH, '...');
+ }
+
+ /**
+ * Returns the currently set message, or null if no message is set yet.
+ * @return string|null
+ */
+ public function getMessage(): ?string
+ {
+ return $this->message;
+ }
+
+ /**
+ * Clear the currently set message.
+ */
+ public function clearMessage(): void
+ {
+ $this->message = null;
+ }
+
+ /**
+ * Check if a message is currently set.
+ * @return bool
+ */
+ public function isMessageSet(): bool
+ {
+ return is_string($this->message);
+ }
+}
\ No newline at end of file
diff --git a/src/Services/LogSystem/EventUndoHelper.php b/src/Services/LogSystem/EventUndoHelper.php
new file mode 100644
index 00000000..f61171e9
--- /dev/null
+++ b/src/Services/LogSystem/EventUndoHelper.php
@@ -0,0 +1,90 @@
+.
+ */
+
+namespace App\Services\LogSystem;
+
+
+use App\Entity\LogSystem\AbstractLogEntry;
+
+class EventUndoHelper
+{
+ public const MODE_UNDO = 'undo';
+ public const MODE_REVERT = 'revert';
+
+ protected const ALLOWED_MODES = [self::MODE_REVERT, self::MODE_UNDO];
+
+ protected $undone_event;
+ protected $mode;
+
+ public function __construct()
+ {
+ $undone_event = null;
+ $this->mode = self::MODE_UNDO;
+ }
+
+ public function setMode(string $mode): void
+ {
+ if (!in_array($mode, self::ALLOWED_MODES)) {
+ throw new \InvalidArgumentException('Invalid mode passed!');
+ }
+ $this->mode = $mode;
+ }
+
+ public function getMode(): string
+ {
+ return $this->mode;
+ }
+
+ /**
+ * Set which event log is currently undone.
+ * After the flush this message is cleared.
+ * @param AbstractLogEntry|null $undone_event
+ */
+ public function setUndoneEvent(?AbstractLogEntry $undone_event): void
+ {
+ $this->undone_event = $undone_event;
+ }
+
+ /**
+ * Returns event that is currently undone.
+ * @return AbstractLogEntry|null
+ */
+ public function getUndoneEvent(): ?AbstractLogEntry
+ {
+ return $this->undone_event;
+ }
+
+ /**
+ * Clear the currently the set undone event.
+ */
+ public function clearUndoneEvent(): void
+ {
+ $this->undone_event = null;
+ }
+
+ /**
+ * Check if a event is undone
+ * @return bool
+ */
+ public function isUndo(): bool
+ {
+ return ($this->undone_event instanceof AbstractLogEntry);
+ }
+}
\ No newline at end of file
diff --git a/src/Services/LogSystem/HistoryHelper.php b/src/Services/LogSystem/HistoryHelper.php
new file mode 100644
index 00000000..32ee98b8
--- /dev/null
+++ b/src/Services/LogSystem/HistoryHelper.php
@@ -0,0 +1,61 @@
+.
+ */
+
+namespace App\Services\LogSystem;
+
+
+use App\Entity\Attachments\AttachmentContainingDBElement;
+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 AttachmentContainingDBElement) {
+ $array = array_merge($array, $element->getAttachments()->toArray());
+ }
+
+ if ($element instanceof Part) {
+ $array = array_merge(
+ $array,
+ $element->getPartLots()->toArray(),
+ $element->getOrderdetails()->toArray()
+ );
+ foreach ($element->getOrderdetails() as $orderdetail) {
+ $array = array_merge($array, $orderdetail->getPricedetails()->toArray());
+ }
+ }
+
+ return $array;
+ }
+}
\ No newline at end of file
diff --git a/src/Services/LogSystem/LogEntryExtraFormatter.php b/src/Services/LogSystem/LogEntryExtraFormatter.php
index 434cadf0..b28623d8 100644
--- a/src/Services/LogSystem/LogEntryExtraFormatter.php
+++ b/src/Services/LogSystem/LogEntryExtraFormatter.php
@@ -42,7 +42,10 @@ declare(strict_types=1);
namespace App\Services\LogSystem;
+use App\Entity\Contracts\LogWithCommentInterface;
+use App\Entity\Contracts\LogWithEventUndoInterface;
use App\Entity\LogSystem\AbstractLogEntry;
+use App\Entity\LogSystem\CollectionElementDeleted;
use App\Entity\LogSystem\DatabaseUpdatedLogEntry;
use App\Entity\LogSystem\ElementCreatedLogEntry;
use App\Entity\LogSystem\ElementDeletedLogEntry;
@@ -52,6 +55,8 @@ use App\Entity\LogSystem\InstockChangedLogEntry;
use App\Entity\LogSystem\UserLoginLogEntry;
use App\Entity\LogSystem\UserLogoutLogEntry;
use App\Entity\LogSystem\UserNotAllowedLogEntry;
+use App\Services\ElementTypeNameGenerator;
+use Doctrine\ORM\EntityManagerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
@@ -60,10 +65,15 @@ use Symfony\Contracts\Translation\TranslatorInterface;
class LogEntryExtraFormatter
{
protected $translator;
+ protected $elementTypeNameGenerator;
- public function __construct(TranslatorInterface $translator)
+ protected const CONSOLE_SEARCH = ['', '', '', '', '', ];
+ protected const CONSOLE_REPLACE = ['→', '', '', '', ''];
+
+ public function __construct(TranslatorInterface $translator, ElementTypeNameGenerator $elementTypeNameGenerator)
{
$this->translator = $translator;
+ $this->elementTypeNameGenerator = $elementTypeNameGenerator;
}
/**
@@ -73,32 +83,33 @@ class LogEntryExtraFormatter
*/
public function formatConsole(AbstractLogEntry $logEntry): string
{
- $tmp = $this->format($logEntry);
+ $arr = $this->getInternalFormat($logEntry);
+ $tmp = [];
- //Just a simple tweak to make the console output more pretty.
- $search = ['', '', '', '', ' '];
- $replace = ['', '', '', '', '→'];
+ //Make an array with entries in the form "Key: Value"
+ foreach ($arr as $key => $value) {
+ $str = '';
+ if (is_string($key)) {
+ $str .= '' . $this->translator->trans($key) . ': ';
+ }
+ $str .= $value;
+ if (!empty($str)) {
+ $tmp[] = $str;
+ }
+ }
- return str_replace($search, $replace, $tmp);
+ return str_replace(static::CONSOLE_SEARCH, static::CONSOLE_REPLACE, implode("; ", $tmp));
}
- /**
- * Return a HTML formatted string containing a user viewable form of the Extra data.
- *
- * @return string
- */
- public function format(AbstractLogEntry $context): string
+ protected function getInternalFormat(AbstractLogEntry $context): array
{
+ $array = [];
if ($context instanceof UserLoginLogEntry || $context instanceof UserLogoutLogEntry) {
- return sprintf(
- '%s: %s',
- $this->translator->trans('log.user_login.ip'),
- htmlspecialchars($context->getIPAddress())
- );
+ $array['log.user_login.ip'] = htmlspecialchars($context->getIPAddress());
}
if ($context instanceof ExceptionLogEntry) {
- return sprintf(
+ $array[] = sprintf(
'%s %s:%d : %s',
htmlspecialchars($context->getExceptionClass()),
htmlspecialchars($context->getFile()),
@@ -108,7 +119,7 @@ class LogEntryExtraFormatter
}
if ($context instanceof DatabaseUpdatedLogEntry) {
- return sprintf(
+ $array[] = sprintf(
'%s %s %s',
$this->translator->trans($context->isSuccessful() ? 'log.database_updated.success' : 'log.database_updated.failure'),
$context->getOldVersion(),
@@ -116,42 +127,85 @@ class LogEntryExtraFormatter
);
}
+ if ($context instanceof LogWithEventUndoInterface) {
+ if ($context->isUndoEvent()) {
+ if ($context->getUndoMode() === 'undo') {
+ $array['log.undo_mode.undo'] = (string) $context->getUndoEventID();
+ } elseif ($context->getUndoMode() === 'revert') {
+ $array['log.undo_mode.revert'] = (string) $context->getUndoEventID();
+ }
+ }
+ }
+
+ if ($context instanceof LogWithCommentInterface && $context->hasComment()) {
+ $array[] = htmlspecialchars($context->getComment());
+ }
+
if ($context instanceof ElementCreatedLogEntry && $context->hasCreationInstockValue()) {
- return sprintf(
- '%s: %s',
- $this->translator->trans('log.element_created.original_instock'),
- $context->getCreationInstockValue()
- );
+ $array['log.element_created.original_instock'] = (string) $context->getCreationInstockValue();
}
if ($context instanceof ElementDeletedLogEntry) {
- return sprintf(
- '%s: %s',
- $this->translator->trans('log.element_deleted.old_name'),
- $context->getOldName() ?? $this->translator->trans('log.element_deleted.old_name.unknown')
- );
+ if ($context->getOldName() !== null) {
+ $array['log.element_deleted.old_name'] = htmlspecialchars($context->getOldName());
+ } else {
+ $array['log.element_deleted.old_name'] = $this->translator->trans('log.element_deleted.old_name.unknown');
+ }
}
- if ($context instanceof ElementEditedLogEntry && ! empty($context->getMessage())) {
- return htmlspecialchars($context->getMessage());
+ if ($context instanceof ElementEditedLogEntry && $context->hasChangedFieldsInfo()) {
+ $array['log.element_edited.changed_fields'] = htmlspecialchars(implode(', ', $context->getChangedFields()));
}
if ($context instanceof InstockChangedLogEntry) {
- return sprintf(
- '%s; %s %s (%s); %s: %s',
- $this->translator->trans($context->isWithdrawal() ? 'log.instock_changed.withdrawal' : 'log.instock_changed.added'),
+ $array[] = $this->translator->trans($context->isWithdrawal() ? 'log.instock_changed.withdrawal' : 'log.instock_changed.added');
+ $array[] = sprintf(
+ '%s %s (%s)',
$context->getOldInstock(),
$context->getNewInstock(),
- (! $context->isWithdrawal() ? '+' : '-').$context->getDifference(true),
- $this->translator->trans('log.instock_changed.comment'),
- htmlspecialchars($context->getComment())
+ (! $context->isWithdrawal() ? '+' : '-').$context->getDifference(true)
+ );
+ $array['log.instock_changed.comment'] = htmlspecialchars($context->getComment());
+ }
+
+ if ($context instanceof CollectionElementDeleted) {
+ $array['log.collection_deleted.deleted'] = sprintf(
+ '%s: %s (%s)',
+ $this->elementTypeNameGenerator->getLocalizedTypeLabel($context->getDeletedElementClass()),
+ $context->getOldName() ?? $context->getDeletedElementID(),
+ $context->getCollectionName()
);
}
if ($context instanceof UserNotAllowedLogEntry) {
- return htmlspecialchars($context->getMessage());
+ $array[] = htmlspecialchars($context->getMessage());
}
- return '';
+ return $array;
+ }
+
+ /**
+ * Return a HTML formatted string containing a user viewable form of the Extra data.
+ *
+ * @return string
+ */
+ public function format(AbstractLogEntry $context): string
+ {
+ $arr = $this->getInternalFormat($context);
+ $tmp = [];
+
+ //Make an array with entries in the form "Key: Value"
+ foreach ($arr as $key => $value) {
+ $str = '';
+ if (is_string($key)) {
+ $str .= '' . $this->translator->trans($key) . ': ';
+ }
+ $str .= $value;
+ if (!empty($str)) {
+ $tmp[] = $str;
+ }
+ }
+
+ return implode("; ", $tmp);
}
}
diff --git a/src/Services/LogSystem/TimeTravel.php b/src/Services/LogSystem/TimeTravel.php
new file mode 100644
index 00000000..54e9fa72
--- /dev/null
+++ b/src/Services/LogSystem/TimeTravel.php
@@ -0,0 +1,217 @@
+em = $em;
+ $this->repo = $em->getRepository(AbstractLogEntry::class);
+ }
+
+ /**
+ * Undeletes the element with the given ID.
+ * @param string $class The class name of the element that should be undeleted
+ * @param int $id The ID of the element that should be undeleted.
+ * @return AbstractDBElement
+ */
+ public function undeleteEntity(string $class, int $id): AbstractDBElement
+ {
+ $log = $this->repo->getUndeleteDataForElement($class, $id);
+ $element = new $class();
+ $this->applyEntry($element, $log);
+
+ //Set internal ID so the element can be reverted
+ $this->setField($element, 'id', $id);
+
+ //Let database determine when it will be created
+ $this->setField($element,'addedDate', null);
+
+ return $element;
+ }
+
+ /**
+ * Revert the given element to the state it has on the given timestamp
+ * @param AbstractDBElement $element
+ * @param \DateTime $timestamp
+ * @param AbstractLogEntry[] $reverted_elements
+ * @throws \Exception
+ */
+ 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) {
+ if ($logEntry instanceof ElementEditedLogEntry) {
+ $this->applyEntry($element, $logEntry);
+ }
+ if ($logEntry instanceof CollectionElementDeleted) {
+ //Undelete element and add it to collection again
+ $undeleted = $this->undeleteEntity(
+ $logEntry->getDeletedElementClass(),
+ $logEntry->getDeletedElementID()
+ );
+ if ($this->repo->getElementExistedAtTimestamp($undeleted, $timestamp)) {
+ $this->revertEntityToTimestamp($undeleted, $timestamp, $reverted_elements);
+ $collection = $this->getField($element, $logEntry->getCollectionName());
+ if ($collection instanceof Collection) {
+ $collection->add($undeleted);
+ }
+ }
+ }
+ }
+
+ // 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 (one element in property)
+ 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 ( //Revert *_TO_MANY associations (collection properties)
+ ($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);
+ }
+ }
+ }
+
+ }
+ }
+
+ /**
+ * Apply the changeset in the given LogEntry to the element
+ * @param AbstractDBElement $element
+ * @param TimeTravelInterface $logEntry
+ * @throws \Doctrine\ORM\Mapping\MappingException
+ */
+ 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)) {
+ $mapping = $metadata->getAssociationMapping($field);
+ $target_class = $mapping['targetEntity'];
+ //Try to extract the old ID:
+ if (is_array($data) && isset($data['@id'])) {
+ $entity = $this->em->getPartialReference($target_class, $data['@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/AdminPages/EntityAdminBase.html.twig b/templates/AdminPages/EntityAdminBase.html.twig
index 2c96c401..f298b841 100644
--- a/templates/AdminPages/EntityAdminBase.html.twig
+++ b/templates/AdminPages/EntityAdminBase.html.twig
@@ -1,5 +1,15 @@
{% extends "main_card.html.twig" %}
+{% block card_type %}
+ {% if timeTravel is defined and timeTravel is not null %}
+ bg-primary-striped text-white
+ {% else %}
+ bg-primary text-white
+ {% endif %}
+{% endblock %}
+
+{% form_theme form.log_comment 'bootstrap_4_layout.html.twig' %}
+
{% block card_content %}
@@ -33,16 +43,25 @@
+ {% if timeTravel is defined and timeTravel is not null %}
+ {% trans with {'%timestamp%': timeTravel|format_datetime('short')} %}part.info.timetravel_hint{% endtrans %}
+ {% endif %}
{{ form_errors(form) }}
+
{{ form_row(form.reset) }}
{{ form_end(form) }}
@@ -112,11 +145,15 @@
{% include "AdminPages/_info.html.twig" %}
-
+ {% if datatable is defined and datatable is not null %}
+
+ {% include "LogSystem/_log_table.html.twig" %}
+
+ {% endif %}
{% if entity.id %}
- {% include 'AdminPages/_export_form.html.twig' with {'path' : path('attachment_type_export', {'id': entity.id})} %}
+ {% include 'AdminPages/_export_form.html.twig' with {'path' : path(route_base ~ '_export', {'id': entity.id})} %}
{% else %} {# For new element we have a combined import/export tab #}
{% set delete_disabled = (not is_granted("delete", entity)) or (entity.group is defined and entity.id == 1) %}
-
- {% if entity.parent is defined %}
-
-
-
+
+
+
+
+
+
+
+
+ {% if entity.parent is defined %}
+
+
+
+
{% endif %}
diff --git a/templates/LogSystem/_log_table.html.twig b/templates/LogSystem/_log_table.html.twig
new file mode 100644
index 00000000..0b432932
--- /dev/null
+++ b/templates/LogSystem/_log_table.html.twig
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/templates/LogSystem/log_list.html.twig b/templates/LogSystem/log_list.html.twig
index 1b2cc2ed..5ad1d7a3 100644
--- a/templates/LogSystem/log_list.html.twig
+++ b/templates/LogSystem/log_list.html.twig
@@ -3,14 +3,5 @@
{% block title %}{% trans %}log.list.title{% endtrans %}{% endblock %}
{% block content %}
-
-
-
-
-
{% trans %}part_list.loading.caption{% endtrans %}
-
{% trans %}part_list.loading.message{% endtrans %}
-
-
-
-
+ {% include "LogSystem/_log_table.html.twig" %}
{% endblock %}
\ No newline at end of file
diff --git a/templates/Parts/edit/edit_part_info.html.twig b/templates/Parts/edit/edit_part_info.html.twig
index ec8d1c4f..b57e7653 100644
--- a/templates/Parts/edit/edit_part_info.html.twig
+++ b/templates/Parts/edit/edit_part_info.html.twig
@@ -84,11 +84,23 @@
+ {% if datatable is not null %}
+ {% include "LogSystem/_log_table.html.twig" %}
+ {% endif %}
+
\ No newline at end of file
diff --git a/templates/Parts/info/_main_infos.html.twig b/templates/Parts/info/_main_infos.html.twig
index 51564746..ebe321a4 100644
--- a/templates/Parts/info/_main_infos.html.twig
+++ b/templates/Parts/info/_main_infos.html.twig
@@ -23,7 +23,9 @@
{{ part.name }}
{# You need edit permission to use the edit button #}
- {% if is_granted('edit', part) %}
+ {% if timeTravel is not null %}
+
+ {% elseif is_granted('edit', part) %}
{% endif %}
diff --git a/templates/Parts/info/_sidebar.html.twig b/templates/Parts/info/_sidebar.html.twig
index 2d3f715a..f6272fa1 100644
--- a/templates/Parts/info/_sidebar.html.twig
+++ b/templates/Parts/info/_sidebar.html.twig
@@ -1,5 +1,9 @@
{% import "helper.twig" as helper %}
+{% if timeTravel is not null %}
+ {% trans with {'%timestamp%': timeTravel|format_datetime('short')} %}part.info.timetravel_hint{% endtrans %}
+{% endif %}
+