Add audit trail: AuditLog entity, AuditService, admin logs page
- AuditLog entity: action, entityType, entityId, data (JSON), performedBy, ipAddress - AuditService: logs actions with current user and IP - Audit on: order_created, order_paid, order_cancelled, order_refunded - Admin /admin/logs: paginated table with action badges, details, user, IP - Navigation link 'Logs' in admin header Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,7 @@
|
|||||||
- [ ] Admin : liste de toutes les commandes avec filtres
|
- [ ] Admin : liste de toutes les commandes avec filtres
|
||||||
- [ ] Admin : pouvoir suspendre un organisateur
|
- [ ] Admin : pouvoir suspendre un organisateur
|
||||||
- [ ] Admin : pouvoir modifier l'offre/commission d'un orga existant (déjà fait partiellement)
|
- [ ] Admin : pouvoir modifier l'offre/commission d'un orga existant (déjà fait partiellement)
|
||||||
- [ ] Admin : logs des actions importantes (audit trail)
|
- [x] Admin : logs des actions importantes (audit trail: commande, paiement, annulation, remboursement)
|
||||||
|
|
||||||
### UX & Pages
|
### UX & Pages
|
||||||
- [x] Page /tarifs : détailler les 3 offres (free/basic/custom) avec commissions et exemples
|
- [x] Page /tarifs : détailler les 3 offres (free/basic/custom) avec commissions et exemples
|
||||||
|
|||||||
28
migrations/Version20260322120000.php
Normal file
28
migrations/Version20260322120000.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260322120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create audit_log table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE audit_log (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, action VARCHAR(50) NOT NULL, entity_type VARCHAR(255) NOT NULL, entity_id INT DEFAULT NULL, data JSON NOT NULL, performed_by VARCHAR(255) DEFAULT NULL, ip_address VARCHAR(45) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_AUDIT_ACTION ON audit_log (action)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_AUDIT_CREATED ON audit_log (created_at)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE audit_log');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ parameters:
|
|||||||
- src/Kernel.php
|
- src/Kernel.php
|
||||||
ignoreErrors:
|
ignoreErrors:
|
||||||
-
|
-
|
||||||
message: '#Property App\\Entity\\(EmailTracking|MessengerLog|User|Payout|Event|Category|Billet|BilletDesign|BilletBuyer|BilletBuyerItem|BilletOrder|OrganizerInvitation)::\$id .* never assigned#'
|
message: '#Property App\\Entity\\(EmailTracking|MessengerLog|User|Payout|Event|Category|Billet|BilletDesign|BilletBuyer|BilletBuyerItem|BilletOrder|OrganizerInvitation|AuditLog)::\$id .* never assigned#'
|
||||||
reportUnmatched: false
|
reportUnmatched: false
|
||||||
paths:
|
paths:
|
||||||
- src/Entity/EmailTracking.php
|
- src/Entity/EmailTracking.php
|
||||||
@@ -21,6 +21,7 @@ parameters:
|
|||||||
- src/Entity/BilletBuyerItem.php
|
- src/Entity/BilletBuyerItem.php
|
||||||
- src/Entity/BilletOrder.php
|
- src/Entity/BilletOrder.php
|
||||||
- src/Entity/OrganizerInvitation.php
|
- src/Entity/OrganizerInvitation.php
|
||||||
|
- src/Entity/AuditLog.php
|
||||||
-
|
-
|
||||||
message: '#Parameter \#1 \$params of method Stripe\\Service\\.*::create\(\) expects#'
|
message: '#Parameter \#1 \$params of method Stripe\\Service\\.*::create\(\) expects#'
|
||||||
path: src/Controller/OrderController.php
|
path: src/Controller/OrderController.php
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Entity\Category;
|
|||||||
use App\Entity\Event;
|
use App\Entity\Event;
|
||||||
use App\Entity\Payout;
|
use App\Entity\Payout;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Service\AuditService;
|
||||||
use App\Service\BilletOrderService;
|
use App\Service\BilletOrderService;
|
||||||
use App\Service\EventIndexService;
|
use App\Service\EventIndexService;
|
||||||
use App\Service\MailerService;
|
use App\Service\MailerService;
|
||||||
@@ -817,7 +818,7 @@ class AccountController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/mon-compte/evenement/{id}/commande/{orderId}/annuler', name: 'app_account_event_cancel_order', methods: ['POST'])]
|
#[Route('/mon-compte/evenement/{id}/commande/{orderId}/annuler', name: 'app_account_event_cancel_order', methods: ['POST'])]
|
||||||
public function cancelOrder(Event $event, int $orderId, EntityManagerInterface $em): Response
|
public function cancelOrder(Event $event, int $orderId, EntityManagerInterface $em, AuditService $audit): Response
|
||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||||
|
|
||||||
@@ -841,6 +842,11 @@ class AccountController extends AbstractController
|
|||||||
|
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|
||||||
|
$audit->log('order_cancelled', 'BilletBuyer', $order->getId(), [
|
||||||
|
'orderNumber' => $order->getOrderNumber(),
|
||||||
|
'event' => $event->getTitle(),
|
||||||
|
]);
|
||||||
|
|
||||||
$this->addFlash('success', 'Commande '.$order->getOrderNumber().' annulee.');
|
$this->addFlash('success', 'Commande '.$order->getOrderNumber().' annulee.');
|
||||||
|
|
||||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'stats']);
|
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'stats']);
|
||||||
@@ -850,7 +856,7 @@ class AccountController extends AbstractController
|
|||||||
* @codeCoverageIgnore Requires live Stripe API
|
* @codeCoverageIgnore Requires live Stripe API
|
||||||
*/
|
*/
|
||||||
#[Route('/mon-compte/evenement/{id}/commande/{orderId}/rembourser', name: 'app_account_event_refund_order', methods: ['POST'])]
|
#[Route('/mon-compte/evenement/{id}/commande/{orderId}/rembourser', name: 'app_account_event_refund_order', methods: ['POST'])]
|
||||||
public function refundOrder(Event $event, int $orderId, EntityManagerInterface $em, StripeService $stripeService): Response
|
public function refundOrder(Event $event, int $orderId, EntityManagerInterface $em, StripeService $stripeService, AuditService $audit): Response
|
||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||||
|
|
||||||
@@ -884,6 +890,12 @@ class AccountController extends AbstractController
|
|||||||
|
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|
||||||
|
$audit->log('order_refunded', 'BilletBuyer', $order->getId(), [
|
||||||
|
'orderNumber' => $order->getOrderNumber(),
|
||||||
|
'event' => $event->getTitle(),
|
||||||
|
'totalHT' => $order->getTotalHTDecimal(),
|
||||||
|
]);
|
||||||
|
|
||||||
$this->addFlash('success', 'Commande '.$order->getOrderNumber().' remboursee.');
|
$this->addFlash('success', 'Commande '.$order->getOrderNumber().' remboursee.');
|
||||||
|
|
||||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'stats']);
|
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'stats']);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\AuditLog;
|
||||||
use App\Entity\OrganizerInvitation;
|
use App\Entity\OrganizerInvitation;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Service\MailerService;
|
use App\Service\MailerService;
|
||||||
@@ -443,6 +444,17 @@ class AdminController extends AbstractController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route('/logs', name: 'app_admin_logs', methods: ['GET'])]
|
||||||
|
public function logs(Request $request, EntityManagerInterface $em, PaginatorInterface $paginator): Response
|
||||||
|
{
|
||||||
|
$logs = $em->getRepository(AuditLog::class)->findBy([], ['createdAt' => 'DESC']);
|
||||||
|
$paginatedLogs = $paginator->paginate($logs, $request->query->getInt('page', 1), 30);
|
||||||
|
|
||||||
|
return $this->render('admin/logs.html.twig', [
|
||||||
|
'logs' => $paginatedLogs,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/organisateurs/inviter', name: 'app_admin_invite_organizer', methods: ['GET', 'POST'])]
|
#[Route('/organisateurs/inviter', name: 'app_admin_invite_organizer', methods: ['GET', 'POST'])]
|
||||||
public function inviteOrganizer(Request $request, EntityManagerInterface $em, MailerService $mailerService): Response
|
public function inviteOrganizer(Request $request, EntityManagerInterface $em, MailerService $mailerService): Response
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Entity\BilletBuyerItem;
|
|||||||
use App\Entity\BilletOrder;
|
use App\Entity\BilletOrder;
|
||||||
use App\Entity\Event;
|
use App\Entity\Event;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Service\AuditService;
|
||||||
use App\Service\BilletOrderService;
|
use App\Service\BilletOrderService;
|
||||||
use App\Service\InvoiceService;
|
use App\Service\InvoiceService;
|
||||||
use App\Service\StripeService;
|
use App\Service\StripeService;
|
||||||
@@ -22,7 +23,7 @@ class OrderController extends AbstractController
|
|||||||
private const REF_PATTERN = 'ETICKET-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}';
|
private const REF_PATTERN = 'ETICKET-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}';
|
||||||
|
|
||||||
#[Route('/evenement/{id}/commander', name: 'app_order_create', requirements: ['id' => '\d+'], methods: ['POST'])]
|
#[Route('/evenement/{id}/commander', name: 'app_order_create', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||||
public function create(int $id, Request $request, EntityManagerInterface $em): Response
|
public function create(int $id, Request $request, EntityManagerInterface $em, AuditService $audit): Response
|
||||||
{
|
{
|
||||||
$event = $em->getRepository(Event::class)->find($id);
|
$event = $em->getRepository(Event::class)->find($id);
|
||||||
if (!$event || !$event->isOnline()) {
|
if (!$event || !$event->isOnline()) {
|
||||||
@@ -67,6 +68,12 @@ class OrderController extends AbstractController
|
|||||||
$em->persist($order);
|
$em->persist($order);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|
||||||
|
$audit->log('order_created', 'BilletBuyer', $order->getId(), [
|
||||||
|
'orderNumber' => $order->getOrderNumber(),
|
||||||
|
'event' => $event->getTitle(),
|
||||||
|
'totalHT' => $order->getTotalHTDecimal(),
|
||||||
|
]);
|
||||||
|
|
||||||
$redirect = $user
|
$redirect = $user
|
||||||
? $this->generateUrl('app_order_payment', ['id' => $order->getId()])
|
? $this->generateUrl('app_order_payment', ['id' => $order->getId()])
|
||||||
: $this->generateUrl('app_order_guest', ['id' => $order->getId()]);
|
: $this->generateUrl('app_order_guest', ['id' => $order->getId()]);
|
||||||
@@ -185,7 +192,7 @@ class OrderController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/commande/{id}/confirmation', name: 'app_order_success', requirements: ['id' => '\d+'], methods: ['GET'])]
|
#[Route('/commande/{id}/confirmation', name: 'app_order_success', requirements: ['id' => '\d+'], methods: ['GET'])]
|
||||||
public function success(int $id, Request $request, EntityManagerInterface $em, BilletOrderService $billetOrderService, StripeService $stripeService): Response
|
public function success(int $id, Request $request, EntityManagerInterface $em, BilletOrderService $billetOrderService, StripeService $stripeService, AuditService $audit): Response
|
||||||
{
|
{
|
||||||
$order = $em->getRepository(BilletBuyer::class)->find($id);
|
$order = $em->getRepository(BilletBuyer::class)->find($id);
|
||||||
if (!$order) {
|
if (!$order) {
|
||||||
@@ -198,6 +205,10 @@ class OrderController extends AbstractController
|
|||||||
if ('succeeded' === $redirectStatus && BilletBuyer::STATUS_PENDING === $order->getStatus()) {
|
if ('succeeded' === $redirectStatus && BilletBuyer::STATUS_PENDING === $order->getStatus()) {
|
||||||
$this->savePaymentDetails($order, $paymentIntentId, $stripeService, $em);
|
$this->savePaymentDetails($order, $paymentIntentId, $stripeService, $em);
|
||||||
$billetOrderService->generateOrderTickets($order);
|
$billetOrderService->generateOrderTickets($order);
|
||||||
|
$audit->log('order_paid', 'BilletBuyer', $order->getId(), [
|
||||||
|
'orderNumber' => $order->getOrderNumber(),
|
||||||
|
'totalHT' => $order->getTotalHTDecimal(),
|
||||||
|
]);
|
||||||
$billetOrderService->generateAndSendTickets($order);
|
$billetOrderService->generateAndSendTickets($order);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
105
src/Entity/AuditLog.php
Normal file
105
src/Entity/AuditLog.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\AuditLogRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: AuditLogRepository::class)]
|
||||||
|
class AuditLog
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 50)]
|
||||||
|
private string $action;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private string $entityType;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?int $entityId = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
private array $data = [];
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $performedBy = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 45, nullable: true)]
|
||||||
|
private ?string $ipAddress = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private \DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
public function __construct(string $action, string $entityType, ?int $entityId = null)
|
||||||
|
{
|
||||||
|
$this->action = $action;
|
||||||
|
$this->entityType = $entityType;
|
||||||
|
$this->entityId = $entityId;
|
||||||
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAction(): string
|
||||||
|
{
|
||||||
|
return $this->action;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEntityType(): string
|
||||||
|
{
|
||||||
|
return $this->entityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEntityId(): ?int
|
||||||
|
{
|
||||||
|
return $this->entityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getData(): array
|
||||||
|
{
|
||||||
|
return $this->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setData(array $data): static
|
||||||
|
{
|
||||||
|
$this->data = $data;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPerformedBy(): ?string
|
||||||
|
{
|
||||||
|
return $this->performedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPerformedBy(?string $performedBy): static
|
||||||
|
{
|
||||||
|
$this->performedBy = $performedBy;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIpAddress(): ?string
|
||||||
|
{
|
||||||
|
return $this->ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIpAddress(?string $ipAddress): static
|
||||||
|
{
|
||||||
|
$this->ipAddress = $ipAddress;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/Repository/AuditLogRepository.php
Normal file
18
src/Repository/AuditLogRepository.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\AuditLog;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<AuditLog>
|
||||||
|
*/
|
||||||
|
class AuditLogRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, AuditLog::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/Service/AuditService.php
Normal file
37
src/Service/AuditService.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\AuditLog;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
|
||||||
|
class AuditService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
private Security $security,
|
||||||
|
private RequestStack $requestStack,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function log(string $action, string $entityType, ?int $entityId = null, array $data = []): void
|
||||||
|
{
|
||||||
|
$log = new AuditLog($action, $entityType, $entityId);
|
||||||
|
$log->setData($data);
|
||||||
|
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
if ($user) {
|
||||||
|
$log->setPerformedBy($user->getUserIdentifier());
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
|
if ($request) {
|
||||||
|
$log->setIpAddress($request->getClientIp());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->persist($log);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
<a href="{{ path('app_admin_buyers') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route starts with 'app_admin_buyer' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Acheteurs</a>
|
<a href="{{ path('app_admin_buyers') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route starts with 'app_admin_buyer' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Acheteurs</a>
|
||||||
<a href="{{ path('app_admin_organizers') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route starts with 'app_admin_organizer' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Organisateurs</a>
|
<a href="{{ path('app_admin_organizers') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route starts with 'app_admin_organizer' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Organisateurs</a>
|
||||||
<a href="{{ path('app_admin_events') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route starts with 'app_admin_event' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Evenements</a>
|
<a href="{{ path('app_admin_events') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route starts with 'app_admin_event' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Evenements</a>
|
||||||
|
<a href="{{ path('app_admin_logs') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route == 'app_admin_logs' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Logs</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
|||||||
69
templates/admin/logs.html.twig
Normal file
69
templates/admin/logs.html.twig
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
{% extends 'admin/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Logs{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page">Audit trail</h1>
|
||||||
|
<p class="font-bold text-gray-500 italic">{{ logs.getTotalItemCount }} action{{ logs.getTotalItemCount > 1 ? 's' : '' }}.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-card !p-0">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-[10px] font-black uppercase tracking-widest text-white">Date</th>
|
||||||
|
<th class="text-[10px] font-black uppercase tracking-widest text-white">Action</th>
|
||||||
|
<th class="text-[10px] font-black uppercase tracking-widest text-white">Entite</th>
|
||||||
|
<th class="text-[10px] font-black uppercase tracking-widest text-white">Details</th>
|
||||||
|
<th class="text-[10px] font-black uppercase tracking-widest text-white">Par</th>
|
||||||
|
<th class="text-[10px] font-black uppercase tracking-widest text-white">IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in logs %}
|
||||||
|
<tr class="hover:bg-gray-50 transition-all">
|
||||||
|
<td class="text-xs text-gray-500 whitespace-nowrap">{{ log.createdAt|date('d/m/Y H:i:s') }}</td>
|
||||||
|
<td>
|
||||||
|
{% if log.action == 'order_created' %}
|
||||||
|
<span class="admin-badge-yellow text-xs font-black uppercase">Commande</span>
|
||||||
|
{% elseif log.action == 'order_paid' %}
|
||||||
|
<span class="admin-badge-green text-xs font-black uppercase">Payee</span>
|
||||||
|
{% elseif log.action == 'order_cancelled' %}
|
||||||
|
<span class="admin-badge-red text-xs font-black uppercase">Annulee</span>
|
||||||
|
{% elseif log.action == 'order_refunded' %}
|
||||||
|
<span class="admin-badge-yellow text-xs font-black uppercase">Remboursee</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="admin-badge-indigo text-xs font-black uppercase">{{ log.action }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-xs text-gray-600">{{ log.entityType }} #{{ log.entityId }}</td>
|
||||||
|
<td class="text-xs text-gray-500">
|
||||||
|
{% for key, value in log.data %}
|
||||||
|
<span class="font-bold">{{ key }}</span>: {{ value }}{% if not loop.last %}, {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="text-xs text-gray-600">{{ log.performedBy ?? '—' }}</td>
|
||||||
|
<td class="text-xs text-gray-400 font-mono">{{ log.ipAddress ?? '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="!text-center !py-12 text-gray-400 font-bold text-sm">Aucun log.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if logs.getTotalItemCount > 30 %}
|
||||||
|
<div class="flex justify-center gap-2 mt-6">
|
||||||
|
{% for page in 1..logs.getPageCount %}
|
||||||
|
{% if page == logs.getCurrentPageNumber %}
|
||||||
|
<span class="admin-page-active text-xs font-black">{{ page }}</span>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ path('app_admin_logs', {page: page}) }}" class="admin-page-link text-xs font-black hover:bg-gray-100 transition-all">{{ page }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
50
tests/Entity/AuditLogTest.php
Normal file
50
tests/Entity/AuditLogTest.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Entity;
|
||||||
|
|
||||||
|
use App\Entity\AuditLog;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class AuditLogTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testConstructor(): void
|
||||||
|
{
|
||||||
|
$log = new AuditLog('order_created', 'BilletBuyer', 42);
|
||||||
|
|
||||||
|
self::assertNull($log->getId());
|
||||||
|
self::assertSame('order_created', $log->getAction());
|
||||||
|
self::assertSame('BilletBuyer', $log->getEntityType());
|
||||||
|
self::assertSame(42, $log->getEntityId());
|
||||||
|
self::assertSame([], $log->getData());
|
||||||
|
self::assertNull($log->getPerformedBy());
|
||||||
|
self::assertNull($log->getIpAddress());
|
||||||
|
self::assertInstanceOf(\DateTimeImmutable::class, $log->getCreatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetData(): void
|
||||||
|
{
|
||||||
|
$log = new AuditLog('test', 'Entity');
|
||||||
|
$result = $log->setData(['key' => 'value']);
|
||||||
|
|
||||||
|
self::assertSame(['key' => 'value'], $log->getData());
|
||||||
|
self::assertSame($log, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetPerformedBy(): void
|
||||||
|
{
|
||||||
|
$log = new AuditLog('test', 'Entity');
|
||||||
|
$result = $log->setPerformedBy('admin@test.fr');
|
||||||
|
|
||||||
|
self::assertSame('admin@test.fr', $log->getPerformedBy());
|
||||||
|
self::assertSame($log, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetIpAddress(): void
|
||||||
|
{
|
||||||
|
$log = new AuditLog('test', 'Entity');
|
||||||
|
$result = $log->setIpAddress('127.0.0.1');
|
||||||
|
|
||||||
|
self::assertSame('127.0.0.1', $log->getIpAddress());
|
||||||
|
self::assertSame($log, $result);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user