Add support for audit event dumping and improve subject rendering flexibility

- Added `AuditEventDumper` class to generate and store CSV exports of audit events based on search criteria.
- Updated `SubjectDisplayerInterface` and related classes to support multiple output formats (`html` or `string`) for subject rendering.
This commit is contained in:
2026-02-17 18:49:14 +01:00
parent b89911e307
commit a0796852dd
5 changed files with 143 additions and 5 deletions

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Audit;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Repository\AuditTrailRepository;
use Chill\MainBundle\Entity\AuditTrail;
use Chill\MainBundle\Templating\Entity\UserRender;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class AuditEventDumper
{
public function __construct(
private StoredObjectManagerInterface $storedObjectManager,
private AuditTrailRepository $auditTrailRepository,
private TranslatorInterface $translator,
private SubjectConverterManagerInterface $converterManager,
private EntityManagerInterface $entityManager,
private UserRender $userRender,
) {}
public function dump(array $criteria, string $lang): StoredObject
{
// Create a temporary file on disk (not in memory)
$tmpPath = tempnam(sys_get_temp_dir(), 'audit_export_');
if (false === $tmpPath) {
throw new \RuntimeException('Unable to create a temporary file for audit dump');
}
$handle = fopen($tmpPath, 'wb');
if (false === $handle) {
throw new \RuntimeException('Unable to open temporary file for writing');
}
// CSV header (optional, but helpful)
fputcsv($handle, [
'occurred_at',
'user_id',
'user_label',
'audit_subject',
'action',
'description',
'metadata',
'associated_subjects',
]);
$formatter = \IntlDateFormatter::create(
$lang,
\IntlDateFormatter::SHORT,
\IntlDateFormatter::LONG,
);
$i = 0;
foreach ($this->auditTrailRepository->findByCriteriaIterator($criteria) as $audit) {
\assert($audit instanceof AuditTrail);
$occurredAt = $formatter?->format($audit->getOccurredAt()) ?: $audit->getOccurredAt()->format('c');
$user = $audit->getUser();
$userId = $user?->getId();
$userLabel = $user ? $this->userRender->renderString($user, ['at_date' => $audit->getOccurredAt()]) : '';
// Displayable name of the audit (main subject)
$mainSubjectDisplay = '';
$mainSubject = $audit->getMainSubject();
if (null !== $mainSubject) {
$mainSubjectDisplay = $this->converterManager->display(Subject::fromArray($mainSubject), 'string');
}
// Translated action
$action = $this->translator->trans('audit_trail.action.'.$audit->getAction(), [], null, $lang);
// Metadata JSON
$metadata = json_encode($audit->getMetadata(), JSON_THROW_ON_ERROR);
// Associated subjects, comma separated
$subjectsDisplay = '';
$subjects = $audit->getSubjects();
if (!empty($subjects)) {
$subjectsDisplay = implode(', ', array_map(function (array $s): string {
return $this->converterManager->display(Subject::fromArray($s), 'fr');
}, $subjects));
}
fputcsv($handle, [
$occurredAt,
(string) ($userId ?? ''),
$userLabel,
$mainSubjectDisplay,
$action,
$audit->getDescription(),
$metadata,
$subjectsDisplay,
]);
++$i;
if (0 === $i % 100) {
// free memory from managed entities every 100 lines
$this->entityManager->clear();
}
}
fclose($handle);
// Create a stored object, set auto-removal after 24h, and store content
$storedObject = new StoredObject();
$storedObject->setDeleteAt((new \DateTimeImmutable('now'))->modify('+24 hours'));
// Persist stored object so that a valid identifier exists in DB
$this->entityManager->persist($storedObject);
$this->entityManager->flush();
$content = file_get_contents($tmpPath) ?: '';
$this->storedObjectManager->write($storedObject, $content, 'text/csv');
// Remove temp file from disk
@unlink($tmpPath);
return $storedObject;
}
}

View File

@@ -43,11 +43,11 @@ final readonly class SubjectConverterManager implements SubjectConverterManagerI
throw new ConvertSubjectException($subject);
}
public function display(Subject $subject, array $options = []): string
public function display(Subject $subject, string $format = 'html', array $options = []): string
{
foreach ($this->displayers as $displayer) {
if ($displayer->supportsDisplay($subject, $options = [])) {
return $displayer->display($subject, $options = []);
if ($displayer->supportsDisplay($subject)) {
return $displayer->display($subject, $format, $options);
}
}

View File

@@ -15,5 +15,8 @@ interface SubjectConverterManagerInterface
{
public function getSubjectsForEntity(object $subject, bool $includeAssociated = false): SubjectBag;
public function display(Subject $subject): string;
/**
* @param 'html'|'string' $format
*/
public function display(Subject $subject, string $format = 'html', array $options = []): string;
}

View File

@@ -21,7 +21,9 @@ interface SubjectDisplayerInterface
/**
* Render a Subject as an HTML string.
*
* @param 'html'|'string' $format the output format
*
* @return string an html string
*/
public function display(Subject $subject, array $options = []): string;
public function display(Subject $subject, string $format = 'html', array $options = []): string;
}

View File

@@ -23,6 +23,7 @@ final readonly class SubjectRenderRuntimeTwig implements RuntimeExtensionInterfa
{
return $this->subjectConverterManager->display(
$subject instanceof Subject ? $subject : Subject::fromArray($subject),
'html',
$options
);
}