diff --git a/src/CoreBundle/EventListener/DcGeneral/DefinitionBuilder/DataProviderBuilder.php b/src/CoreBundle/EventListener/DcGeneral/DefinitionBuilder/DataProviderBuilder.php index fa2d43846..fe1537a8f 100644 --- a/src/CoreBundle/EventListener/DcGeneral/DefinitionBuilder/DataProviderBuilder.php +++ b/src/CoreBundle/EventListener/DcGeneral/DefinitionBuilder/DataProviderBuilder.php @@ -96,6 +96,12 @@ protected function build(IMetaModelDataDefinition $container) ->setClassName(Driver::class) ->setInitializationData(['source' => $container->getName()]) ->setVersioningEnabled(false); + // MetaModels\CoreBundle\EventListener\DcGeneral\LogPersistedItemsListener logs items + // with their rendered label instead - logging both would duplicate every entry under + // two different wordings. A separate statement, not chained onto the calls above: + // setVersioningEnabled() returns the wider DataProviderInformationInterface, which + // does not declare setLoggingEnabled(). + $providerInformation->setLoggingEnabled(false); $basicDefinition->setDataProvider($container->getName()); } diff --git a/src/CoreBundle/EventListener/DcGeneral/LogPersistedItemsListener.php b/src/CoreBundle/EventListener/DcGeneral/LogPersistedItemsListener.php new file mode 100644 index 000000000..ee39b7fd5 --- /dev/null +++ b/src/CoreBundle/EventListener/DcGeneral/LogPersistedItemsListener.php @@ -0,0 +1,159 @@ + + * @copyright 2012-2026 The MetaModels team. + * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later + * @filesource + */ + +declare(strict_types=1); + +namespace MetaModels\CoreBundle\EventListener\DcGeneral; + +use ContaoCommunityAlliance\DcGeneral\Data\ModelInterface; +use ContaoCommunityAlliance\DcGeneral\Event\PostDeleteModelEvent; +use ContaoCommunityAlliance\DcGeneral\Event\PostDuplicateModelEvent; +use ContaoCommunityAlliance\DcGeneral\Event\PostPersistModelEvent; +use MetaModels\CoreBundle\Backend\ItemLabelRenderer; +use MetaModels\IFactory; +use MetaModels\ViewCombination\ViewCombination; +use Psr\Log\LoggerInterface; + +/** + * Logs creating, duplicating and deleting a MetaModel item to the Contao system log (tl_log), the + * way dc-general's own generic LogPersistedModelsListener does for every other DC_General table - + * except with the item named the same way the edit mask headline and breadcrumb already do + * (ItemLabelRenderer, the input screen's "subheadline" pattern), instead of a bare table+id. See + * ".claude/dcg-systemlog.md". + * + * MetaModels items opt out of the generic listener entirely (DataProviderBuilder sets + * setLoggingEnabled(false) on their provider information) so that a create/duplicate/delete is not + * logged twice under two different wordings. The three MetaModels configuration tables + * (tl_metamodel_rendersettings and friends) are unaffected and keep using the generic listener - a + * bare table+id is all that is meaningful for those anyway. + * + * Deliberately not "edit", for the same reason as the generic listener: Contao's own tables do not + * log edits either, the version history is what covers that. + */ +final class LogPersistedItemsListener +{ + public function __construct( + private readonly IFactory $factory, + private readonly ViewCombination $viewCombination, + private readonly ItemLabelRenderer $labelRenderer, + private readonly LoggerInterface $logger, + ) { + } + + /** + * Log the creation of a new item. + * + * @param PostPersistModelEvent $event The event. + * + * @return void + */ + public function onPersist(PostPersistModelEvent $event): void + { + // Edits fire this same event with the previously stored data as original model. A create + // is not signalled by a null original model - CreateHandler passes an empty one + // (getEmptyModel()), never a literal null - it has no id yet, which is what actually tells + // the two apart. + $originalModel = $event->getOriginalModel(); + if (null !== $originalModel && null !== $originalModel->getId()) { + return; + } + + $this->log( + $event->getModel(), + fn (string $label): string => \sprintf('A new entry "%s" has been created', $label) + ); + } + + /** + * Log the creation of an item by duplicating another one. + * + * @param PostDuplicateModelEvent $event The event. + * + * @return void + */ + public function onDuplicate(PostDuplicateModelEvent $event): void + { + $sourceLabel = $this->describe($event->getSourceModel()); + + $this->log( + $event->getModel(), + fn (string $label): string => \sprintf( + 'A new entry "%s" has been created by duplicating record "%s"', + $label, + $sourceLabel + ) + ); + } + + /** + * Log the deletion of an item. + * + * @param PostDeleteModelEvent $event The event. + * + * @return void + */ + public function onDelete(PostDeleteModelEvent $event): void + { + $this->log($event->getModel(), fn (string $label): string => \sprintf('Deleted entry "%s"', $label)); + } + + /** + * Write the log entry for a model, unless it is not a MetaModel item or logging is off for it. + * + * @param ModelInterface $model The model. + * @param callable $message Builds the log message from the model's rendered label. + * + * @return void + */ + private function log(ModelInterface $model, callable $message): void + { + $tableName = $model->getProviderName(); + if (!\in_array($tableName, $this->factory->collectNames(), true)) { + return; + } + + $metaModel = $this->factory->getMetaModel($tableName); + if (null === $metaModel || !(bool) $metaModel->get('enableLogging')) { + return; + } + + $this->logger->info($message($this->describe($model))); + } + + /** + * Name a model the same way its edit mask headline and breadcrumb do. + * + * @param ModelInterface $model The model. + * + * @return string + */ + private function describe(ModelInterface $model): string + { + $tableName = $model->getProviderName(); + $metaModel = $this->factory->getMetaModel($tableName); + $modelName = null !== $metaModel ? $metaModel->getName() : $tableName; + + /** @var array|null $screen */ + $screen = $this->viewCombination->getScreen($tableName); + $pattern = (string) ($screen['meta']['subheadline'] ?? ''); + $label = $this->labelRenderer->render($pattern, $model->getPropertiesAsArray()); + + return $modelName . ': ' . ('' !== $label ? $label : (string) $model->getId()); + } +} diff --git a/src/CoreBundle/Resources/config/services.yml b/src/CoreBundle/Resources/config/services.yml index e50b88110..ff20a2c6a 100644 --- a/src/CoreBundle/Resources/config/services.yml +++ b/src/CoreBundle/Resources/config/services.yml @@ -36,6 +36,24 @@ services: - "@router" - "@request_stack" + metamodels.listener.dcgeneral.log_persisted_items: + class: MetaModels\CoreBundle\EventListener\DcGeneral\LogPersistedItemsListener + arguments: + - "@metamodels.factory" + - "@metamodels.view_combination" + - "@metamodels.backend.item_label_renderer" + - "@monolog.logger.contao.general" + tags: + - name: kernel.event_listener + event: dc-general.model.post-persist + method: onPersist + - name: kernel.event_listener + event: dc-general.model.post-duplicate + method: onDuplicate + - name: kernel.event_listener + event: dc-general.model.post-delete + method: onDelete + metamodels.assets.icon_builder: class: MetaModels\CoreBundle\Assets\IconBuilder arguments: diff --git a/src/CoreBundle/Resources/contao/dca/tl_metamodel.php b/src/CoreBundle/Resources/contao/dca/tl_metamodel.php index 2032d8581..91a5be14b 100644 --- a/src/CoreBundle/Resources/contao/dca/tl_metamodel.php +++ b/src/CoreBundle/Resources/contao/dca/tl_metamodel.php @@ -405,7 +405,8 @@ ], 'advanced' => [ ':hide', - 'varsupport' + 'varsupport', + 'enableLogging' ], ] ], @@ -532,5 +533,18 @@ ], 'sql' => "char(1) NOT NULL default ''" ], + // Mirrors what Contao logs for its own tables (create/duplicate/delete) into tl_log - see + // .claude/dcg-systemlog.md. Default on, matching Contao's own tables, which cannot be + // switched off either. + 'enableLogging' => [ + 'label' => 'enableLogging.label', + 'description' => 'enableLogging.description', + 'exclude' => true, + 'inputType' => 'checkbox', + 'eval' => [ + 'tl_class' => 'clr w50 cbx m12' + ], + 'sql' => "char(1) NOT NULL default '1'" + ], ], ]; diff --git a/src/CoreBundle/Resources/translations/tl_metamodel.de.xlf b/src/CoreBundle/Resources/translations/tl_metamodel.de.xlf index 85100b313..f8cf7ea51 100644 --- a/src/CoreBundle/Resources/translations/tl_metamodel.de.xlf +++ b/src/CoreBundle/Resources/translations/tl_metamodel.de.xlf @@ -137,6 +137,14 @@ Check if this MetaModel shall support language territory at locale. Mit der Checkbox wird die Territory-Angabe bei dem Sprachkey unterstützt. + + Log changes to the system log + Änderungen im Systemlog protokollieren + + + Writes creating, duplicating and deleting an item of this MetaModel to the Contao system log, the same way Contao does for its own tables. Editing an existing item is not logged, matching Contao - the version history covers that instead. + Schreibt Anlegen, Duplizieren und Löschen eines Items dieses MetaModels ins Contao-Systemlog, genauso wie Contao es für seine eigenen Tabellen tut. Das Bearbeiten eines vorhandenen Items wird - wie bei Contao - nicht protokolliert, dafür ist die Versionierung da. + Sorting Sortierung diff --git a/src/CoreBundle/Resources/translations/tl_metamodel.en.xlf b/src/CoreBundle/Resources/translations/tl_metamodel.en.xlf index 7ecc40145..95beb00eb 100644 --- a/src/CoreBundle/Resources/translations/tl_metamodel.en.xlf +++ b/src/CoreBundle/Resources/translations/tl_metamodel.en.xlf @@ -104,6 +104,12 @@ Check if this MetaModel shall support language territory at locale. + + Log changes to the system log + + + Writes creating, duplicating and deleting an item of this MetaModel to the Contao system log, the same way Contao does for its own tables. Editing an existing item is not logged, matching Contao - the version history covers that instead. + Sorting diff --git a/tests/CoreBundle/EventListener/DcGeneral/LogPersistedItemsListenerTest.php b/tests/CoreBundle/EventListener/DcGeneral/LogPersistedItemsListenerTest.php new file mode 100644 index 000000000..91f23c874 --- /dev/null +++ b/tests/CoreBundle/EventListener/DcGeneral/LogPersistedItemsListenerTest.php @@ -0,0 +1,205 @@ + + * @copyright 2012-2026 The MetaModels team. + * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later + * @filesource + */ + +declare(strict_types=1); + +namespace MetaModels\CoreBundle\Test\EventListener\DcGeneral; + +use Contao\CoreBundle\String\SimpleTokenParser; +use ContaoCommunityAlliance\DcGeneral\Data\ModelInterface; +use ContaoCommunityAlliance\DcGeneral\EnvironmentInterface; +use ContaoCommunityAlliance\DcGeneral\Event\PostDeleteModelEvent; +use ContaoCommunityAlliance\DcGeneral\Event\PostDuplicateModelEvent; +use ContaoCommunityAlliance\DcGeneral\Event\PostPersistModelEvent; +use MetaModels\CoreBundle\Backend\ItemLabelRenderer; +use MetaModels\CoreBundle\EventListener\DcGeneral\LogPersistedItemsListener; +use MetaModels\IFactory; +use MetaModels\IMetaModel; +use MetaModels\ViewCombination\ViewCombination; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Tests for LogPersistedItemsListener - see ".claude/dcg-systemlog.md" for the background. + */ +#[CoversClass(LogPersistedItemsListener::class)] +final class LogPersistedItemsListenerTest extends TestCase +{ + private function mockModel(string $tableName, string $id, array $properties = []): ModelInterface + { + $model = $this->createMock(ModelInterface::class); + $model->method('getProviderName')->willReturn($tableName); + $model->method('getId')->willReturn($id); + $model->method('getPropertiesAsArray')->willReturn($properties); + + return $model; + } + + private function mockMetaModel(string $name, bool $enableLogging): IMetaModel + { + $metaModel = $this->createMock(IMetaModel::class); + $metaModel->method('getName')->willReturn($name); + $metaModel->method('get')->with('enableLogging')->willReturn($enableLogging); + + return $metaModel; + } + + private function mockEnvironment(): EnvironmentInterface + { + return $this->createMock(EnvironmentInterface::class); + } + + /** + * What CreateHandler actually passes as the original model on a create - + * DataProviderInterface::getEmptyModel(), not a literal null. + */ + private function mockEmptyModel(): ModelInterface + { + $model = $this->createMock(ModelInterface::class); + $model->method('getId')->willReturn(null); + + return $model; + } + + private function listener( + string $tableName, + ?IMetaModel $metaModel, + ?array $screen = null, + ?LoggerInterface $logger = null, + ): LogPersistedItemsListener { + $factory = $this->createMock(IFactory::class); + $factory->method('collectNames')->willReturn(null === $metaModel ? [] : [$tableName]); + $factory->method('getMetaModel')->with($tableName)->willReturn($metaModel); + + $viewCombination = $this->createMock(ViewCombination::class); + $viewCombination->method('getScreen')->with($tableName)->willReturn($screen); + + $tokenParser = $this->createMock(SimpleTokenParser::class); + $tokenParser->method('parse')->willReturnCallback( + function (string $pattern, array $tokens): string { + self::assertSame('##model_name##', $pattern); + + return (string) ($tokens['model_name'] ?? ''); + } + ); + + return new LogPersistedItemsListener( + $factory, + $viewCombination, + new ItemLabelRenderer($tokenParser), + $logger ?? $this->createMock(LoggerInterface::class) + ); + } + + public function testLogsCreationWithTheRenderedItemLabel(): void + { + $metaModel = $this->mockMetaModel('Employees', true); + $model = $this->mockModel('mm_employees', '5', ['name' => 'Jane Doe']); + $screen = ['meta' => ['subheadline' => '##model_name##']]; + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('info')->with( + 'A new entry "Employees: Jane Doe" has been created' + ); + + $listener = $this->listener('mm_employees', $metaModel, $screen, $logger); + $listener->onPersist(new PostPersistModelEvent($this->mockEnvironment(), $model, null)); + } + + public function testLogsCreationWhenTheOriginalModelIsAnEmptyPlaceholder(): void + { + // The real path (CreateHandler): the original model is never a literal null, only unset. + $metaModel = $this->mockMetaModel('Employees', true); + $model = $this->mockModel('mm_employees', '5', ['name' => 'Jane Doe']); + $screen = ['meta' => ['subheadline' => '##model_name##']]; + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('info')->with( + 'A new entry "Employees: Jane Doe" has been created' + ); + + $listener = $this->listener('mm_employees', $metaModel, $screen, $logger); + $listener->onPersist(new PostPersistModelEvent($this->mockEnvironment(), $model, $this->mockEmptyModel())); + } + + public function testDoesNotLogAnEditOfAnExistingItem(): void + { + $metaModel = $this->mockMetaModel('Employees', true); + $model = $this->mockModel('mm_employees', '5'); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('info'); + + $listener = $this->listener('mm_employees', $metaModel, null, $logger); + $listener->onPersist(new PostPersistModelEvent($this->mockEnvironment(), $model, $model)); + } + + public function testFallsBackToTheRecordIdWithoutAPattern(): void + { + $metaModel = $this->mockMetaModel('Employees', true); + $model = $this->mockModel('mm_employees', '5'); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('info')->with('Deleted entry "Employees: 5"'); + + $listener = $this->listener('mm_employees', $metaModel, null, $logger); + $listener->onDelete(new PostDeleteModelEvent($this->mockEnvironment(), $model)); + } + + public function testLogsDuplicationWithBothLabels(): void + { + $metaModel = $this->mockMetaModel('Employees', true); + $model = $this->mockModel('mm_employees', '9', ['name' => 'Jane Doe']); + $source = $this->mockModel('mm_employees', '5', ['name' => 'John Doe']); + $screen = ['meta' => ['subheadline' => '##model_name##']]; + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('info')->with( + 'A new entry "Employees: Jane Doe" has been created by duplicating record "Employees: John Doe"' + ); + + $listener = $this->listener('mm_employees', $metaModel, $screen, $logger); + $listener->onDuplicate(new PostDuplicateModelEvent($this->mockEnvironment(), $model, $source)); + } + + public function testStaysSilentWhenLoggingIsDisabledForTheMetaModel(): void + { + $metaModel = $this->mockMetaModel('Employees', false); + $model = $this->mockModel('mm_employees', '5'); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('info'); + + $listener = $this->listener('mm_employees', $metaModel, null, $logger); + $listener->onDelete(new PostDeleteModelEvent($this->mockEnvironment(), $model)); + } + + public function testIgnoresModelsThatAreNotMetaModelItems(): void + { + $model = $this->mockModel('tl_metamodel_rendersettings', '5'); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('info'); + + // No MetaModel registered for this table - collectNames() stays empty. + $listener = $this->listener('tl_metamodel_rendersettings', null, null, $logger); + $listener->onDelete(new PostDeleteModelEvent($this->mockEnvironment(), $model)); + } +}