Skip to content

Commit

Permalink
Add History feature with entity Trait/Interface and dedicated History…
Browse files Browse the repository at this point in the history
…Logger service
  • Loading branch information
mathieu-ducrot committed Jun 17, 2024
1 parent 695771c commit 09e1f93
Show file tree
Hide file tree
Showing 13 changed files with 534 additions and 3 deletions.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"symfony/apache-pack": "^1.0",
"symfony/form": "^5.4|^6.2",
"symfony/framework-bundle": "^5.4|^6.2",
"symfony/security-bundle": "^5.4|^6.2",
"theofidry/alice-data-fixtures": "^1.5"
},
"require-dev": {
Expand Down
1 change: 1 addition & 0 deletions config/bundles.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@
Nelmio\SecurityBundle\NelmioSecurityBundle::class => ['all' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['all' => true],
Sentry\SentryBundle\SentryBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
];
5 changes: 5 additions & 0 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Nécéssaire pour faire marcher le make cc sinon les dépendances aux services security ne sont pas chargés
security:
firewalls:
dev:
security: false
25 changes: 22 additions & 3 deletions config/services.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
services:
# Alice
# Service Fidry\AliceDataFixtures\Loader\PurgerLoader not exist, and it must be aliased. AbstractFixtures need it.
Fidry\AliceDataFixtures\Loader\PurgerLoader: '@fidry_alice_data_fixtures.doctrine.purger_loader'
# Command
Smart\CoreBundle\Command\CommandPoolHelper:
arguments:
Expand All @@ -14,6 +17,25 @@ services:
- setContainer: [ '@service_container' ]
- setEntityManager: [ '@Doctrine\ORM\EntityManagerInterface' ]
tags: [ 'controller.service_arguments' ]
# EventListener
Smart\CoreBundle\EventListener\HistoryDoctrineListener:
arguments:
- '@Smart\CoreBundle\Logger\HistoryLogger'
tags:
- {name: doctrine.event_listener, event: prePersist, priority: 500}
- {name: doctrine.event_listener, event: preUpdate, priority: 500}
Smart\CoreBundle\EventListener\HistoryLoggerListener:
arguments:
- '@request_stack'
- '@security.token_storage'
- '@Smart\CoreBundle\Logger\HistoryLogger'
- '%env(DOMAIN)%'
tags:
- {name: kernel.event_subscriber}
# Logger
Smart\CoreBundle\Logger\HistoryLogger:
arguments:
- '@Doctrine\ORM\EntityManagerInterface'
# Monitoring
Smart\CoreBundle\Monitoring\ApiCallMonitor:
arguments:
Expand All @@ -28,6 +50,3 @@ services:
- { name: routing.loader }
# Sentry
Smart\CoreBundle\Sentry\SentryCallback:
# Alice
# Service Fidry\AliceDataFixtures\Loader\PurgerLoader not exist, and it must be aliased. AbstractFixtures need it.
Fidry\AliceDataFixtures\Loader\PurgerLoader: '@fidry_alice_data_fixtures.doctrine.purger_loader'
22 changes: 22 additions & 0 deletions src/Entity/Log/HistorizableInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace Smart\CoreBundle\Entity\Log;

/**
* @author Mathieu Ducrot <[email protected]>
*/
interface HistorizableInterface
{
public function getId(): ?int;

public function getHistory(): ?array;

public function setHistory(?array $history): self;

public function addHistory(array $history): self;

/**
* Permet d'activer ou non l'historique sur les events doctrine prePersist/preUpdate pour les diffs
*/
public function isDoctrineListenerEnable(): bool;
}
12 changes: 12 additions & 0 deletions src/Entity/Log/HistorizableStatusInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace Smart\CoreBundle\Entity\Log;

/**
* Permet d'avoir un affichage spécifique sur les transitions de status dans l'historique
* @author Mathieu Ducrot <[email protected]>
*/
interface HistorizableStatusInterface
{
public function getStatus(): mixed;
}
42 changes: 42 additions & 0 deletions src/Entity/Log/HistorizableTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace Smart\CoreBundle\Entity\Log;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

/**
* @author Mathieu Ducrot <[email protected]>
*/
trait HistorizableTrait
{
#[ORM\Column(type: Types::JSON, nullable: true)]
protected ?array $history = null;

public function getHistory(): ?array
{
return $this->history;
}

public function setHistory(?array $history): self
{
$this->history = $history;

return $this;
}

public function addHistory(array $history): self
{
if ($this->history == null) {
$this->history = [];
}
array_unshift($this->history, $history);

return $this;
}

public function isDoctrineListenerEnable(): bool
{
return true;
}
}
8 changes: 8 additions & 0 deletions src/Entity/User/UserProfileInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace Smart\CoreBundle\Entity\User;

interface UserProfileInterface extends \Stringable
{
public function getProfile(): string;
}
143 changes: 143 additions & 0 deletions src/EventListener/HistoryDoctrineListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

namespace Smart\CoreBundle\EventListener;

use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\Persistence\Event\LifecycleEventArgs;
use Smart\CoreBundle\Entity\Log\HistorizableInterface;
use Smart\CoreBundle\Entity\Log\HistorizableStatusInterface;
use Smart\CoreBundle\Logger\HistoryLogger;

/**
* @author Mathieu Ducrot <[email protected]>
*/
class HistoryDoctrineListener
{
private bool $enabled = true;
private string $prePersistCode = HistoryLogger::CREATED_CODE;
private ?array $historyExtraData = null;

public function __construct(private HistoryLogger $historyLogger)
{
}

// @param typé avec LifecycleEventArgs sinon le load des fixtures ne fonctionne pas
// devrait pouvoir passer à Doctrine\ORM\Event\PrePersistEventArgs après update de doctrine/orm 2.14
public function prePersist(LifecycleEventArgs $args): void
{
$this->handleHistory($args, $this->prePersistCode);
}

public function preUpdate(PreUpdateEventArgs $args): void
{
$this->handleHistory($args, HistoryLogger::UPDATED_CODE);
}

private function handleHistory(LifecycleEventArgs $args, string $code): void
{
$entity = $args->getObject();
if (!$entity instanceof HistorizableInterface || !$this->enabled || !$entity->isDoctrineListenerEnable()) {
return;
}

$historyData = [];
if ($code === HistoryLogger::UPDATED_CODE) {
/** @var PreUpdateEventArgs $args */
$entityData = $args->getEntityChangeSet();

// Si update avec uniquement history on skip pour ne pas créer de doublon d'history (cas email)
if (count($entityData) === 1 && isset($entityData['history'])) {
return;
}

$statusDiff = null;
$isHistorizableStatus = $entity instanceof HistorizableStatusInterface;
foreach ($entityData as $field => $change) {
if ($field === 'history' || $field === 'updatedAt' || $field === 'updatedAtMonth' || $field === 'updatedAtYear') {
unset($entityData[$field]);
continue;
}
if ($field === 'password') {
$changes = [
'f' => '**********',
't' => '**********',
];
} else {
$changes = [
'f' => $this->serializeDiffValue($change[0]),
't' => $this->serializeDiffValue($change[1]),
];
}
if ($isHistorizableStatus && $field === HistoryLogger::STATUS_PROPERTY) {
$statusDiff = $changes;
unset($entityData[$field]);
continue;
}
$entityData[$field] = $changes;
}
if ($isHistorizableStatus) {
$historyData[HistoryLogger::STATUS_PROPERTY] = $statusDiff;
}

// MDT diff des collections ManyToMany, actuellement pas possible d'avoir l'état de la collection avant preUpdate
$uow = $args->getObjectManager()->getUnitOfWork();
$entityClass = get_class($entity);
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
if ($entityClass !== get_class($collection->getOwner())) {
continue;
}
// MDT c_u clé raccourci pour collection_update
$entityData[$collection->getMapping()['fieldName']]['c_u'] = $collection->map(function ($item) {
return $this->serializeDiffValue($item);
})->toArray();
}
// MDT l'event preUpdate n'est pas trigger si la seul modif de l'event concerne la suppression de collection
// cf. https://github.com/doctrine/orm/issues/9960
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
$entityData[$collection->getMapping()['fieldName']] = 'label.empty';
}
$historyData[HistoryLogger::DIFF_PROPERTY] = $entityData;
}
if (isset($historyData[HistoryLogger::DIFF_PROPERTY]['archivedAt'])) {
$code = HistoryLogger::ARCHIVED_CODE;
unset($historyData[HistoryLogger::DIFF_PROPERTY]['archivedAt']);
}

if ($this->historyExtraData !== null) {
$historyData = array_merge($historyData, $this->historyExtraData);
}

// Pas besoin de setFlushLog car il a déjà lieu après l'event doctrine prePersist/preUpdate
$this->historyLogger->log($entity, $code, $historyData);
}

private function serializeDiffValue(mixed $value): mixed
{
if ($value instanceof \DateTimeInterface) {
if ($value->format('d/m/Y') === '01/01/1970') {
return $value->format('H\hi');
}

return $value->format('Y-m-d\TH:i:sP');
} elseif ($value instanceof \Stringable) {
return (string) $value;
}

return $value;
}

public function disable(): void
{
$this->enabled = false;
}

public function setPrePersistCode(string $prePersistCode): void
{
$this->prePersistCode = $prePersistCode;
}

public function addHistoryExtraData(?array $historyExtraData): void
{
$this->historyExtraData = $historyExtraData;
}
}
76 changes: 76 additions & 0 deletions src/EventListener/HistoryLoggerListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

namespace Smart\CoreBundle\EventListener;

use Smart\CoreBundle\Entity\User\UserProfileInterface;
use Smart\CoreBundle\Logger\HistoryLogger;
use Smart\CoreBundle\Utils\RequestUtils;
use Sonata\AdminBundle\Controller\CRUDController;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;

/**
* Controller to init internal variables of the HistoryLogger
*
* @author Mathieu Ducrot <[email protected]>
*/
class HistoryLoggerListener implements EventSubscriberInterface
{
private string $context;

public function __construct(
protected RequestStack $requestStack,
protected TokenStorageInterface $tokenStorage,
protected HistoryLogger $historyLogger,
protected string $domain,
) {
}

public static function getSubscribedEvents(): array
{
return [
KernelEvents::CONTROLLER => 'onKernelController',
];
}

public function onKernelController(ControllerEvent $event): void
{
// Set the context base on the domain
$request = $this->requestStack->getCurrentRequest();
$this->context = RequestUtils::getContextFromHost($request->getHost(), $this->domain);
$this->historyLogger->setContext($this->context);

// Set the origin based on the action controller
if (is_array($event->getController())) {
/** @var string $controllerClass */
$controllerClass = get_class($event->getController()[0]);
$controllerAction = $event->getController()[1];
} else {
$controllerClass = null;
$controllerAction = null;
}
$isCrudController = str_ends_with($controllerClass, '\CRUDController');
if ($isCrudController && $controllerAction === 'createAction') {
$this->historyLogger->setOrigin('h.crt_f');
} elseif ($isCrudController && $controllerAction === 'editAction') {
$this->historyLogger->setOrigin('h.upd_f');
} elseif ($isCrudController && $controllerAction === 'importAction') {
$this->historyLogger->setOrigin('h.imp_f');
} elseif ($isCrudController && $controllerAction === 'archiveAction') {
$this->historyLogger->setOrigin('h.arc_a');
} elseif ($controllerClass !== null && str_ends_with($controllerClass, '\SecurityController') && $controllerAction === 'profile') {
$this->historyLogger->setOrigin('h.prf_f');
}

// Set the user
$userToken = $this->tokenStorage->getToken();
$user = $userToken?->getUser();
if ($user instanceof UserProfileInterface) {
$this->historyLogger->setUser((string) $user);
$this->historyLogger->setUserProfile($user->getProfile());
}
}
}
Loading

0 comments on commit 09e1f93

Please sign in to comment.