├── .gitignore ├── .sensiolabs.yml ├── .travis.yml ├── ActivityLogBundle.php ├── DependencyInjection ├── ActivityLogExtension.php ├── Compiler │ └── FormatterPass.php └── Configuration.php ├── Dockerfile-56 ├── Entity ├── Interfaces │ ├── ArrayableInterface.php │ ├── LoggableChildInterface.php │ └── StringableInterface.php ├── LogEntry.php ├── LogEntryInterface.php └── MappedSuperclass │ └── AbstractLogEntry.php ├── LICENSE ├── Listener └── LoggableListener.php ├── README.md ├── Repository └── LogEntryRepository.php ├── Resources └── config │ └── services.yml ├── Service └── ActivityLog │ ├── ActivityLogFormatter.php │ └── EntityFormatter │ ├── AbstractFormatter.php │ ├── FormatterInterface.php │ └── UniversalFormatter.php ├── Tests ├── Entity │ └── MappedSuperclass │ │ └── AbstractLogEntryTest.php └── Service │ └── ActivityLog │ ├── ActivityLogFormatterTest.php │ └── EntityFormatter │ ├── AbstractFormatterTest.php │ └── UniversalFormatterTest.php ├── composer.json └── phpunit.xml.dist /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | composer.lock 3 | build/* 4 | coveralls.phar 5 | -------------------------------------------------------------------------------- /.sensiolabs.yml: -------------------------------------------------------------------------------- 1 | global_exclude_dirs: 2 | - vendor 3 | - Tests 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | sudo: false 4 | 5 | php: 6 | - 5.6 7 | - 7.0 8 | 9 | before_install: 10 | - composer self-update 11 | - if [ "$SYMFONY_VERSION" != "" ]; then composer require "symfony/symfony:${SYMFONY_VERSION}" --no-update; fi; 12 | 13 | install: 14 | - composer install 15 | - composer require satooshi/php-coveralls 16 | 17 | before_script: 18 | - mkdir -p build/logs 19 | 20 | script: 21 | - ./vendor/bin/phpunit --coverage-clover build/logs/clover.xml 22 | 23 | after_success: 24 | - php vendor/bin/coveralls -v 25 | 26 | matrix: 27 | fast_finish: true 28 | include: 29 | - php: 5.6 30 | env: SYMFONY_VERSION=3.0.* 31 | - php: 7.0 32 | env: SYMFONY_VERSION=3.0.* 33 | 34 | notifications: 35 | email: 36 | recipients: 37 | - madmis@inbox.ru 38 | on_success: never 39 | on_failure: always 40 | 41 | -------------------------------------------------------------------------------- /ActivityLogBundle.php: -------------------------------------------------------------------------------- 1 | addCompilerPass(new FormatterPass()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /DependencyInjection/ActivityLogExtension.php: -------------------------------------------------------------------------------- 1 | processConfiguration($configuration, $configs); 23 | 24 | $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 25 | $loader->load('services.yml'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /DependencyInjection/Compiler/FormatterPass.php: -------------------------------------------------------------------------------- 1 | has('activity_log.formatter')) { 14 | return; 15 | } 16 | 17 | $definition = $container->findDefinition('activity_log.formatter'); 18 | 19 | $formatters = $container->findTaggedServiceIds('activity_log.formatter'); 20 | 21 | foreach ($formatters as $id => $tags) { 22 | foreach ($tags as $attributes) { 23 | $definition->addMethodCall( 24 | 'addFormatter', 25 | array( 26 | new Reference($id), 27 | $attributes["entity"], 28 | ) 29 | ); 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | = 40200) { 21 | $treeBuilder = new TreeBuilder('activity_log'); 22 | $rootNode = $treeBuilder->getRootNode(); 23 | } else { 24 | $treeBuilder = new TreeBuilder(); 25 | $rootNode = $treeBuilder->root('activity_log'); 26 | } 27 | 28 | return $treeBuilder; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Dockerfile-56: -------------------------------------------------------------------------------- 1 | FROM php:5.6 2 | 3 | #+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 | ENV DEBIAN_FRONTEND noninteractive 5 | RUN TERM=xterm 6 | #+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 7 | 8 | #+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 9 | RUN echo 'PS1="\[\033[36m\]\u\[\033[m\]@\[\033[95;1m\]php:\[\033[34m\]\w\[\033[m\]\$ "' >> ~/.bashrc 10 | #+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 11 | 12 | RUN apt-get update && apt-get install -y \ 13 | git 14 | 15 | #+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 16 | RUN php -r "readfile('https://getcomposer.org/installer');" | php && chmod +x composer.phar && mv composer.phar /usr/local/bin/composer 17 | RUN composer config -g github-oauth.github.com ce3c9b19dc7d59ef066961f3ddc4a1ea2d52126e 18 | 19 | RUN export PATH="~/.composer/vendor/bin/:$PATH" 20 | #+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 21 | 22 | RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 23 | 24 | WORKDIR /var/www 25 | -------------------------------------------------------------------------------- /Entity/Interfaces/ArrayableInterface.php: -------------------------------------------------------------------------------- 1 | parentId; 54 | } 55 | 56 | /** 57 | * @param string $parentId 58 | */ 59 | public function setParentId($parentId) 60 | { 61 | $this->parentId = $parentId; 62 | } 63 | 64 | /** 65 | * @return string 66 | */ 67 | public function getParentClass() 68 | { 69 | return $this->parentClass; 70 | } 71 | 72 | /** 73 | * @param string $parentClass 74 | */ 75 | public function setParentClass($parentClass) 76 | { 77 | $this->parentClass = $parentClass; 78 | } 79 | 80 | /** 81 | * @return array 82 | */ 83 | public function getOldData() 84 | { 85 | return $this->oldData; 86 | } 87 | 88 | /** 89 | * @param array $oldData 90 | */ 91 | public function setOldData(array $oldData) 92 | { 93 | $this->oldData = $oldData; 94 | } 95 | 96 | /** 97 | * @return string 98 | */ 99 | public function getName() 100 | { 101 | return $this->name; 102 | } 103 | 104 | /** 105 | * @param string $name 106 | */ 107 | public function setName($name) 108 | { 109 | $this->name = $name; 110 | } 111 | 112 | /** 113 | * @return UserInterface|null 114 | */ 115 | public function getUser() 116 | { 117 | return $this->user; 118 | } 119 | 120 | /** 121 | * @param UserInterface|null $user 122 | */ 123 | public function setUser($user) 124 | { 125 | $this->user = $user; 126 | } 127 | 128 | /** 129 | * Is action CREATE 130 | * @return bool 131 | */ 132 | public function isCreate() 133 | { 134 | return $this->getAction() === LoggableListener::ACTION_CREATE; 135 | } 136 | 137 | /** 138 | * Is action UPDATE 139 | * @return bool 140 | */ 141 | public function isUpdate() 142 | { 143 | return $this->getAction() === LoggableListener::ACTION_UPDATE; 144 | } 145 | 146 | /** 147 | * Is action DELETE 148 | * @return bool 149 | */ 150 | public function isRemove() 151 | { 152 | return $this->getAction() === LoggableListener::ACTION_REMOVE; 153 | } 154 | 155 | /** 156 | * Get object instance as an array. 157 | * @return array 158 | */ 159 | public function toArray() 160 | { 161 | return [ 162 | 'id' => $this->getId(), 163 | 'name' => $this->getName(), 164 | 'data' => $this->getData(), 165 | 'oldData' => $this->getOldData(), 166 | 'objectClass' => $this->getObjectClass(), 167 | 'objectId' => $this->getObjectId(), 168 | 'parentClass' => $this->getParentClass(), 169 | 'parentId' => $this->getParentId(), 170 | 'action' => $this->getAction(), 171 | 'username' => $this->getUsername(), 172 | 'user' => $this->getUser(), 173 | 'loggedAt' => $this->getLoggedAt(), 174 | 'version' => $this->getVersion(), 175 | ]; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Symfony Bundles (Dmitry Khaperets) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Listener/LoggableListener.php: -------------------------------------------------------------------------------- 1 | getUser() instanceof UserInterface 44 | ) { 45 | $this->user = $username->getUser(); 46 | } 47 | 48 | parent::setUsername($username); 49 | } 50 | 51 | /** 52 | * Looks for loggable objects being inserted or updated 53 | * for further processing 54 | * 55 | * @param EventArgs $eventArgs 56 | * 57 | * @return void 58 | */ 59 | public function onFlush(EventArgs $eventArgs) 60 | { 61 | $this->eventAdapter = $this->getEventAdapter($eventArgs); 62 | 63 | parent::onFlush($eventArgs); 64 | } 65 | 66 | /** 67 | * Handle any custom LogEntry functionality that needs to be performed 68 | * before persisting it 69 | * 70 | * @param LogEntryInterface $logEntry The LogEntry being persisted 71 | * @param object $object The object being Logged 72 | */ 73 | protected function prePersistLogEntry($logEntry, $object) 74 | { 75 | if ($this->user instanceof UserInterface) { 76 | $logEntry->setUser($this->user); 77 | } 78 | 79 | if ($object instanceof StringableInterface) { 80 | $logEntry->setName($object->toString()); 81 | } else { 82 | $logEntry->setName($logEntry->getObjectId()); 83 | } 84 | 85 | if ($this->eventAdapter) { 86 | $om = $this->eventAdapter->getObjectManager(); 87 | /** @var UnitOfWork $uow */ 88 | $uow = $om->getUnitOfWork(); 89 | $wrapped = AbstractWrapper::wrap($object, $om); 90 | $meta = $wrapped->getMetadata(); 91 | $config = $this->getConfiguration($om, $meta->name); 92 | 93 | if ($logEntry->getOldData() === null) { 94 | // save relations to parent entity 95 | if ($object instanceof LoggableChildInterface && $object->getParentEntity() !== null) { 96 | $parent = $object->getParentEntity(); 97 | $parentMeta = AbstractWrapper::wrap($parent, $om)->getMetadata(); 98 | $logEntry->setParentId($parent->getId()); 99 | $logEntry->setParentClass($parentMeta->name); 100 | } 101 | 102 | // don't save old data for new entity, 103 | // because this data duplicate new data 104 | if ($logEntry->isCreate()) { 105 | return; 106 | } 107 | 108 | if (!empty($config['versioned'])) { 109 | $oldValues = []; 110 | $changeSet = $uow->getEntityChangeSet($object); 111 | 112 | foreach ($changeSet as $field => $changes) { 113 | if (empty($config['versioned']) || !in_array($field, $config['versioned'], true)) { 114 | continue; 115 | } 116 | 117 | if (!array_key_exists(0, $changes)) { 118 | continue; 119 | } 120 | $value = $changes[0]; 121 | $oldValues[$field] = $this->getVersionedValue($logEntry, $object, $field, $value); 122 | } 123 | 124 | if ($oldValues) { 125 | $logEntry->setOldData($oldValues); 126 | } 127 | 128 | // save object data when remove 129 | if ($logEntry->isRemove() && $logEntry->getData() === null) { 130 | $origData = $uow->getOriginalEntityData($object); 131 | 132 | if ($origData) { 133 | $values = []; 134 | foreach ($origData as $field => $value) { 135 | if (!in_array($field, $config['versioned'], true)) { 136 | continue; 137 | } 138 | 139 | $values[$field] = $this->getVersionedValue($logEntry, $object, $field, $value); 140 | } 141 | 142 | if ($values) { 143 | $logEntry->setData($values); 144 | } 145 | } 146 | } 147 | } 148 | } 149 | } 150 | } 151 | 152 | /** 153 | * @param LogEntryInterface $logEntry 154 | * @param object $object 155 | * @param string $field 156 | * @param mixed $value 157 | * @return mixed 158 | */ 159 | private function getVersionedValue($logEntry, $object, $field, $value) 160 | { 161 | if ($value) { 162 | $om = $this->eventAdapter->getObjectManager(); 163 | $wrapped = AbstractWrapper::wrap($object, $om); 164 | $meta = $wrapped->getMetadata(); 165 | 166 | if ($meta->isSingleValuedAssociation($field)) { 167 | if ($wrapped->isEmbeddedAssociation($field)) { 168 | $value = $this->getObjectChangeSetData($this->eventAdapter, $value, $logEntry); 169 | } else { 170 | $wrappedAssoc = AbstractWrapper::wrap($value, $om); 171 | $value = $wrappedAssoc->getIdentifier(false); 172 | if (!is_array($value) && !$value) { 173 | return $value; 174 | } 175 | } 176 | } elseif ($value instanceof Proxy) { 177 | $value = ['id' => $value->getId()]; 178 | } 179 | } 180 | 181 | return $value; 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Symfony ActivityLog Component 2 | ================================== 3 | 4 | [![SensioLabsInsight][sensiolabs-insight-image]][sensiolabs-insight-link] 5 | [![Build Status][testing-image]][testing-link] 6 | [![Coverage Status][coverage-image]][coverage-link] 7 | [![Latest Stable Version][stable-image]][package-link] 8 | [![Total Downloads][downloads-image]][package-link] 9 | [![License][license-image]][license-link] 10 | 11 | ActivityLogBundle - Extended doctrine loggable (StofDoctrineExtensionsBundle) 12 | 13 | What's inside 14 | ------------ 15 | 16 | ActivityLogBundle uses **Loggable** extension from [StofDoctrineExtensionsBundle][stof-link] and [DoctrineExtensions][doctrine-link] 17 | 18 | This bundle extend **Gedmo\Loggable\Entity\MappedSuperclass\AbstractLogEntry** with below fields: 19 | 20 | - parentId - store depedency to "main entity" 21 | - parentClass - store "main entity" type 22 | - oldData - data that were changed 23 | - name - entry name (to show in activity log) 24 | - user - associations mapping with user who changed data 25 | 26 | Bundle contain extended listener (**LoggableListener**) to process above fields. 27 | 28 | Also available formatter to preprocessing activity log before show in view (html). 29 | 30 | 31 | Installation 32 | ------------ 33 | Pretty simple with Composer, run: 34 | 35 | ``` bash 36 | composer require madmis/activity-log-bundle 37 | ``` 38 | 39 | Then enable the bundle in the kernel: 40 | 41 | ``` php 42 | public function registerBundles() 43 | { 44 | $bundles = [ 45 | // ... 46 | new ActivityLogBundle\ActivityLogBundle(), 47 | // ... 48 | ]; 49 | ... 50 | } 51 | ``` 52 | 53 | Configure bundle: 54 | 55 | ``` yml 56 | # app/config/config.yml 57 | doctrine: 58 | dbal: 59 | #... 60 | orm: 61 | #... 62 | resolve_target_entities: 63 | Symfony\Component\Security\Core\User\UserInterface: AppBundle\Entity\User 64 | mappings: 65 | gedmo_loggable: 66 | type: annotation 67 | prefix: Gedmo\Loggable\Entity 68 | dir: "%kernel.root_dir%/../src/AppBundle/Entity/" 69 | alias: GedmoLoggable 70 | is_bundle: false 71 | 72 | stof_doctrine_extensions: 73 | class: 74 | loggable: ActivityLogBundle\Listener\LoggableListener 75 | orm: 76 | default: 77 | loggable: true 78 | 79 | ``` 80 | 81 | Create entity and make it loggable: 82 | 83 | ```php 84 | 85 | namespace AppBundle\Entity; 86 | 87 | use Doctrine\ORM\Mapping as ORM; 88 | use Gedmo\Mapping\Annotation as Gedmo; 89 | use ActivityLogBundle\Entity\Interfaces\StringableInterface; 90 | 91 | /** 92 | * @package AppBundle\Entity 93 | * @ORM\Entity(repositoryClass="ProjectRepository") 94 | * @ORM\Table 95 | * @Gedmo\Loggable(logEntryClass="ActivityLogBundle\Entity\LogEntry") 96 | */ 97 | class Project implements StringableInterface 98 | { 99 | /** 100 | * @var int 101 | * @ORM\Id 102 | * @ORM\Column(type="integer") 103 | * @ORM\GeneratedValue(strategy="AUTO") 104 | */ 105 | private $id; 106 | 107 | /** 108 | * @var string 109 | * @ORM\Column(type="string", length=128) 110 | * @Gedmo\Versioned 111 | */ 112 | private $name; 113 | 114 | /** 115 | * @var string 116 | * @ORM\Column(type="string", length=16) 117 | * @Gedmo\Versioned 118 | */ 119 | private $key; 120 | 121 | //... 122 | ``` 123 | **StringableInterface** required to save **LogEntry::name**. 124 | 125 | Then run command to update database schema: 126 | 127 | ``` bash 128 | php bin/console doctrine:schema:update --force 129 | ``` 130 | 131 | Using formatter to data view 132 | ------------ 133 | 134 | Formatter class: **ActivityLogBundle\Service\ActivityLog\ActivityLogFormatter** 135 | Formatter service: **activity_log.formatter** 136 | 137 | required: **LoggerInterface** as dependency 138 | 139 | By default entity without custom formatter class formatted by **ActivityLogBundle\Service\ActivityLog\EntityFormatter\UniversalFormatter** 140 | 141 | But you can implement custom formatter for each entity. 142 | 143 | To register a custom formatter, add a service tag with the following (required) properties: 144 | * name: 'activity_log.formatter' 145 | * entity: Class name of the entity that should be formatted by the registered formatter 146 | 147 | 148 | Example: 149 | ```php 150 | services: 151 | app.formatter.project: 152 | class: AppBundle\Service\ActivityFormatter\Project 153 | tags: 154 | - { name: activity_log.formatter, entity: 'Project'} 155 | ``` 156 | 157 | As example formatter for **AppBundle\Entity\Project** entity: 158 | 159 | ```php 160 | 161 | namespace AppBundle\Service\ActivityFormatter; 162 | 163 | class Project extends AbstractFormatter implements FormatterInterface 164 | { 165 | /** 166 | * @param LogEntryInterface $log 167 | * @return array 168 | */ 169 | public function format(LogEntryInterface $log) 170 | { 171 | $result = $log->toArray(); 172 | 173 | if ($log->isCreate()) { 174 | $result['message'] = sprintf('The Project "%s" was created.', $log->getName()); 175 | } else if ($log->isRemove()) { 176 | $result['message'] = sprintf('The Project "%s" was removed.', $log->getName()); 177 | } else if ($log->isUpdate()) { 178 | $result['message'] = '