├── .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'] = '
The Project "%s" was updated.
%s
'; 179 | $data = $log->getData(); 180 | $oldData = $log->getOldData(); 181 | 182 | $text = ''; 183 | foreach ($data as $field => $value) { 184 | $value = $this->normalizeValue($field, $value); 185 | 186 | if (array_key_exists($field, $oldData)) { 187 | $oldValue = $this->normalizeValue($field, $oldData[$field]); 188 | $subText = sprintf('from "%s" to "%s".', $oldValue, $value); 189 | } else { 190 | $subText = sprintf('to "%s".', $value); 191 | } 192 | $text .= sprintf('
Property "%s" was changed: %s
', $field, $subText); 193 | } 194 | 195 | $result['message'] = sprintf($result['message'], $log->getName(), $text); 196 | } else { 197 | $result['message'] = "Undefined action: {$log->getAction()}."; 198 | } 199 | 200 | return $result; 201 | } 202 | } 203 | ``` 204 | 205 | If entity has association with other entity it can be resolved by **AbstractFormatter::normalizeValue**. 206 | This method call method from the entity formatter class, which named as appropriate property. 207 | 208 | For example, **Project** entity has association mapping **ManyToOne** to **Type** entity. 209 | To get **Type** name we can add method **type** to **Project** formatter: 210 | 211 | ```php 212 | namespace AppBundle\Service\ActivityFormatter; 213 | 214 | class Project extends AbstractFormatter implements FormatterInterface 215 | { 216 | //... 217 | 218 | /** 219 | * @param array $value 220 | * @return string 221 | */ 222 | protected function type(array $value) 223 | { 224 | if (isset($value['id'])) { 225 | /** @var Type $entity */ 226 | $entity = $this->entityManager->getRepository('AppBundle:Type') 227 | ->find($value['id']); 228 | 229 | if ($entity) { 230 | return $entity->getName(); 231 | } 232 | } 233 | 234 | return ''; 235 | } 236 | ``` 237 | 238 | As result we have formatted response to show in view. 239 | 240 | Using activity log in controller 241 | ------------ 242 | 243 | ```php 244 | $em = $this->getDoctrine()->getManager(); 245 | // get log entries for entity 246 | $entries = $em 247 | ->getRepository('AppBundle:LogEntry') 248 | ->getLogEntriesQueryBuilder($entity) 249 | ->getQuery() 250 | ->getResult(); 251 | // format log entries to show in the view 252 | $entries = $this 253 | ->get('activity_log.formatter') 254 | ->format($entries); 255 | ``` 256 | 257 | For ```$entity``` should be configured [Entity formatter][formatter-link]. 258 | 259 | 260 | [sensiolabs-insight-link]: https://insight.sensiolabs.com/projects/9b7eb683-a440-4f68-804a-38ae107e75d0 261 | [sensiolabs-insight-image]: https://insight.sensiolabs.com/projects/9b7eb683-a440-4f68-804a-38ae107e75d0/mini.png 262 | 263 | [formatter-link]: #using-formatter-to-data-view 264 | 265 | [package-link]: https://packagist.org/packages/madmis/activity-log-bundle 266 | [downloads-image]: https://poser.pugx.org/madmis/activity-log-bundle/downloads 267 | [stable-image]: https://poser.pugx.org/madmis/activity-log-bundle/v/stable 268 | [license-image]: https://poser.pugx.org/madmis/activity-log-bundle/license 269 | [license-link]: https://packagist.org/packages/madmis/activity-log-bundle 270 | 271 | [testing-link]: https://travis-ci.org/madmis/ActivityLogBundle 272 | [testing-image]: https://travis-ci.org/madmis/ActivityLogBundle.svg?branch=master 273 | 274 | [stof-link]: https://github.com/stof/StofDoctrineExtensionsBundle 275 | [doctrine-link]: https://github.com/Atlantic18/DoctrineExtensions 276 | 277 | [coverage-link]: https://coveralls.io/github/madmis/ActivityLogBundle?branch=master 278 | [coverage-image]: https://coveralls.io/repos/github/madmis/ActivityLogBundle/badge.svg?branch=master 279 | 280 | -------------------------------------------------------------------------------- /Repository/LogEntryRepository.php: -------------------------------------------------------------------------------- 1 | _em); 26 | $objectClass = $wrapped->getMetadata()->name; 27 | $meta = $this->getClassMetadata(); 28 | $dql = "SELECT log FROM {$meta->name} log"; 29 | $dql .= " WHERE (log.objectId = :objectId AND log.objectClass = :objectClass)"; 30 | $dql .= " OR (log.parentId = :parentId AND log.parentClass = :parentClass)"; 31 | $dql .= " ORDER BY log.version DESC, log.loggedAt ASC"; 32 | 33 | $objectId = $wrapped->getIdentifier(); 34 | $q = $this->_em->createQuery($dql); 35 | $q->setParameters([ 36 | 'objectId' => $objectId, 37 | 'objectClass' => $objectClass, 38 | 'parentId' => $objectId, 39 | 'parentClass' => $objectClass, 40 | ]); 41 | 42 | return $q; 43 | } 44 | 45 | /** 46 | * Get the query builder for loading of log entries 47 | * 48 | * @param object $entity 49 | * 50 | * @return QueryBuilder 51 | */ 52 | public function getLogEntriesQueryBuilder($entity) 53 | { 54 | $wrapped = new EntityWrapper($entity, $this->_em); 55 | $meta = $this->getClassMetadata(); 56 | 57 | $builder = $this->_em->createQueryBuilder(); 58 | $or = $builder->expr()->orX( 59 | 'log.objectId = :objectId AND log.objectClass = :objectClass', 60 | 'log.parentId = :parentId AND log.parentClass = :parentClass' 61 | ); 62 | $builder->select('log') 63 | ->from($meta->name, 'log') 64 | ->andWhere($or) 65 | ->addOrderBy('log.loggedAt', 'DESC'); 66 | 67 | $objectClass = $wrapped->getMetadata()->name; 68 | $objectId = $wrapped->getIdentifier(); 69 | $builder->setParameters([ 70 | 'objectId' => $objectId, 71 | 'objectClass' => $objectClass, 72 | 'parentId' => $objectId, 73 | 'parentClass' => $objectClass, 74 | ]); 75 | 76 | return $builder; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Resources/config/services.yml: -------------------------------------------------------------------------------- 1 | services: 2 | activity_log.formatter: 3 | class: ActivityLogBundle\Service\ActivityLog\ActivityLogFormatter 4 | arguments: ["@logger", "@doctrine.orm.default_entity_manager"] 5 | 6 | -------------------------------------------------------------------------------- /Service/ActivityLog/ActivityLogFormatter.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 35 | $this->customFormatters = []; 36 | } 37 | 38 | /** 39 | * @param FormatterInterface $formatter 40 | * @param string $entity 41 | */ 42 | public function addFormatter($formatter, $entity) 43 | { 44 | $implements = in_array( 45 | 'ActivityLogBundle\Service\ActivityLog\EntityFormatter\FormatterInterface', 46 | class_implements($formatter), 47 | true 48 | ); 49 | 50 | if ($implements) { 51 | $this->customFormatters[$entity] = $formatter; 52 | } 53 | } 54 | 55 | /** 56 | * @param array|LogEntry[] $logs 57 | * @return array 58 | */ 59 | public function format(array $logs) 60 | { 61 | $result = []; 62 | foreach ($logs as $log) { 63 | $result[] = $this->getEntryFormatter($log)->format($log); 64 | } 65 | 66 | return $result; 67 | } 68 | 69 | /** 70 | * @param LogEntryInterface|LogEntry $logEntry 71 | * @return FormatterInterface 72 | */ 73 | private function getEntryFormatter(LogEntryInterface $logEntry) 74 | { 75 | $className = substr(strrchr(rtrim($logEntry->getObjectClass(), '\\'), '\\'), 1); 76 | 77 | $formatter = $this->getCustomFormatter($className); 78 | 79 | if (array_key_exists($className, $this->customFormatters)) { 80 | $formatter = $this->customFormatters[$className]; 81 | } 82 | 83 | // Support fully-qualified class names 84 | if (!$formatter) { 85 | $this->logger->warning("For entity {$logEntry->getObjectClass()} don't implemented Activity Log Formatter."); 86 | $formatter = new UniversalFormatter(); 87 | } 88 | 89 | return $formatter; 90 | } 91 | 92 | /** 93 | * @param string $className 94 | * @return FormatterInterface|null 95 | */ 96 | private function getCustomFormatter($className) 97 | { 98 | $formatter = null; 99 | 100 | if (array_key_exists($className, $this->customFormatters)) { 101 | $formatter = $this->customFormatters[$className]; 102 | } 103 | 104 | return $formatter; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Service/ActivityLog/EntityFormatter/AbstractFormatter.php: -------------------------------------------------------------------------------- 1 | $field($value); 21 | } 22 | 23 | if (is_array($value)) { 24 | $value = $this->toComment($value); 25 | } 26 | 27 | return $value; 28 | } 29 | 30 | /** 31 | * Convert assoc array to comment style 32 | * 33 | * @param array $data 34 | * @return string 35 | */ 36 | public function toComment(array $data) 37 | { 38 | $result = []; 39 | foreach ($data as $key => $value) { 40 | $result[] = $key . ': ' . $value . ';'; 41 | } 42 | 43 | return implode(PHP_EOL, $result); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Service/ActivityLog/EntityFormatter/FormatterInterface.php: -------------------------------------------------------------------------------- 1 | toArray(); 21 | 22 | $name = substr(strrchr(rtrim($log->getObjectClass(), '\\'), '\\'), 1); 23 | if ($log->isCreate()) { 24 | $result['message'] = sprintf('The entity %s (%s) was created.', $log->getName(), $name); 25 | } else if ($log->isRemove()) { 26 | $result['message'] = sprintf('The entity %s (%s) was removed.', $log->getName(), $name); 27 | } else if ($log->isUpdate()) { 28 | $result['message'] = sprintf( 29 | 'The entity %s (%s) was updated.
Prev. data: %s
New data: %s', 30 | $log->getName(), 31 | $name, 32 | $this->toComment($log->getOldData()), 33 | $this->toComment($log->getData()) 34 | ); 35 | } else { 36 | $result['message'] = "Undefined action: {$log->getAction()}."; 37 | } 38 | 39 | return $result; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/Entity/MappedSuperclass/AbstractLogEntryTest.php: -------------------------------------------------------------------------------- 1 | getEntityMock(); 12 | $entity->setParentId('parent-id'); 13 | $this->assertEquals('parent-id', $entity->getParentId()); 14 | } 15 | 16 | public function testSetParentClass() 17 | { 18 | $entity = $this->getEntityMock(); 19 | $entity->setParentClass('ParentClass'); 20 | $this->assertEquals('ParentClass', $entity->getParentClass()); 21 | } 22 | 23 | public function testSetUser() 24 | { 25 | $user = $this->getMockBuilder('Symfony\Component\Security\Core\User\UserInterface') 26 | ->getMock(); 27 | $entity = $this->getEntityMock(); 28 | $entity->setUser($user); 29 | $this->assertInstanceOf( 30 | 'Symfony\Component\Security\Core\User\UserInterface', 31 | $entity->getUser() 32 | ); 33 | } 34 | 35 | public function testSetEmptyUser() 36 | { 37 | $entity = $this->getEntityMock(); 38 | 39 | $entity->setUser(null); 40 | $this->assertNull($entity->getUser()); 41 | } 42 | 43 | /** 44 | * @return \PHPUnit_Framework_MockObject_MockObject|AbstractLogEntry 45 | */ 46 | private function getEntityMock() { 47 | return $this->getMockForAbstractClass( 48 | 'ActivityLogBundle\Entity\MappedSuperclass\AbstractLogEntry' 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Tests/Service/ActivityLog/ActivityLogFormatterTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder('Psr\Log\LoggerInterface') 15 | ->getMock(); 16 | $logger->method('warning') 17 | ->willReturn($this->returnValue(null)); 18 | 19 | $factory = new ActivityLogFormatter($logger); 20 | $logEntry = new LogEntry(); 21 | $logEntry->setOldData(['test' => 'test']); 22 | $logEntry->setUsername('username'); 23 | $logEntry->setParentClass('AppBundle\Entity\ParentClass'); 24 | $logEntry->setAction('create'); 25 | $logEntry->setName('Name'); 26 | $logEntry->setParentId('parent-id'); 27 | $logEntry->setData(['test' => 'test1']); 28 | $logEntry->setObjectClass('AppBundle\Entity\ObjectClass'); 29 | $logEntry->setObjectId('object-id'); 30 | $logEntry->setVersion(2); 31 | $result = $factory->format([$logEntry]); 32 | 33 | $this->assertTrue(is_array($result[0])); 34 | $this->assertArrayHasKey('message', $result[0]); 35 | $this->assertEquals('The entity Name (ObjectClass) was created.', $result[0]['message']); 36 | } 37 | 38 | public function testCustomFormat() 39 | { 40 | $logger = $this->getMockBuilder('Psr\Log\LoggerInterface') 41 | ->getMock(); 42 | $logger->method('warning') 43 | ->willReturn($this->returnValue(null)); 44 | 45 | $factory = new ActivityLogFormatter($logger); 46 | $logEntry = new LogEntry(); 47 | $logEntry->setOldData(['test' => 'test']); 48 | $logEntry->setUsername('username'); 49 | $logEntry->setParentClass('AppBundle\Entity\ParentClass'); 50 | $logEntry->setAction('create'); 51 | $logEntry->setName('Name'); 52 | $logEntry->setParentId('parent-id'); 53 | $logEntry->setData(['test' => 'test1']); 54 | $logEntry->setObjectClass('AppBundle\Entity\UniversalFormatter'); 55 | $logEntry->setObjectId('object-id'); 56 | $logEntry->setVersion(2); 57 | $result = $factory->format([$logEntry]); 58 | 59 | $this->assertTrue(is_array($result[0])); 60 | $this->assertArrayHasKey('message', $result[0]); 61 | $this->assertEquals('The entity Name (UniversalFormatter) was created.', $result[0]['message']); 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /Tests/Service/ActivityLog/EntityFormatter/AbstractFormatterTest.php: -------------------------------------------------------------------------------- 1 | getMockForAbstractClass( 12 | 'ActivityLogBundle\Service\ActivityLog\EntityFormatter\AbstractFormatter' 13 | ); 14 | 15 | $result = $stub->normalizeValue('test', ['key' => 'value']); 16 | $this->assertEquals('key: value;', $result); 17 | $result = $stub->normalizeValue('test', 'test'); 18 | $this->assertEquals('test', $result); 19 | $result = $stub->normalizeValue('test', true); 20 | $this->assertTrue($result); 21 | $result = $stub->normalizeValue('test', 1); 22 | $this->assertTrue(is_int($result)); 23 | } 24 | 25 | /** 26 | * This test only for coverage - it's not test any real behaviors 27 | */ 28 | public function testNormalizeValueByMethod() 29 | { 30 | $stub = $this->getMockForAbstractClass( 31 | 'ActivityLogBundle\Service\ActivityLog\EntityFormatter\AbstractFormatter', 32 | [], 33 | '', 34 | true, 35 | true, 36 | true, 37 | ['test'] 38 | ); 39 | $stub->method('test') 40 | ->willReturn('test'); 41 | 42 | $result = $stub->normalizeValue('test', 'test'); 43 | $this->assertEquals('test', $result); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Tests/Service/ActivityLog/EntityFormatter/UniversalFormatterTest.php: -------------------------------------------------------------------------------- 1 | setName('name 1'); 16 | $entry->setAction(LoggableListener::ACTION_CREATE); 17 | $entry->setData(['testProperty' => true]); 18 | $entry->setObjectClass('AppBundle\Entity\Project'); 19 | 20 | $formatter = new UniversalFormatter($this->getEmMock()); 21 | $result = $formatter->format($entry); 22 | 23 | $this->assertTrue(is_array($result)); 24 | $this->assertArrayHasKey('message', $result); 25 | $this->assertEquals('The entity name 1 (Project) was created.', $result['message']); 26 | } 27 | 28 | public function testFormatUpdate() 29 | { 30 | $entry = new LogEntry(); 31 | $entry->setName('name 1'); 32 | $entry->setAction(LoggableListener::ACTION_UPDATE); 33 | $entry->setData(['testProperty' => true]); 34 | $entry->setOldData(['testProperty' => false]); 35 | $entry->setObjectClass('AppBundle\Entity\Project'); 36 | 37 | $formatter = new UniversalFormatter($this->getEmMock()); 38 | $result = $formatter->format($entry); 39 | 40 | $this->assertTrue(is_array($result)); 41 | $this->assertArrayHasKey('message', $result); 42 | $this->assertContains('The entity name 1 (Project) was updated.', $result['message']); 43 | } 44 | 45 | public function testFormatRemove() 46 | { 47 | $entry = new LogEntry(); 48 | $entry->setName('name 1'); 49 | $entry->setAction(LoggableListener::ACTION_REMOVE); 50 | $entry->setData(['testProperty' => true]); 51 | $entry->setObjectClass('AppBundle\Entity\Project'); 52 | 53 | $formatter = new UniversalFormatter($this->getEmMock()); 54 | $result = $formatter->format($entry); 55 | 56 | $this->assertTrue(is_array($result)); 57 | $this->assertArrayHasKey('message', $result); 58 | $this->assertEquals('The entity name 1 (Project) was removed.', $result['message']); 59 | 60 | } 61 | 62 | public function testFormatInvalidAction() 63 | { 64 | $entry = new LogEntry(); 65 | $entry->setName('name 1'); 66 | $entry->setAction('invalid action'); 67 | $entry->setData(['testProperty' => true]); 68 | $entry->setObjectClass('AppBundle\Entity\Project'); 69 | 70 | $formatter = new UniversalFormatter($this->getEmMock()); 71 | $result = $formatter->format($entry); 72 | 73 | $this->assertTrue(is_array($result)); 74 | $this->assertArrayHasKey('message', $result); 75 | $this->assertArrayHasKey('message', $result); 76 | $this->assertEquals('Undefined action: invalid action.', $result['message']); 77 | } 78 | 79 | public function getEmMock() 80 | { 81 | return $this->getMockBuilder('\Doctrine\ORM\EntityManager') 82 | ->getMock(); 83 | } 84 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "madmis/activity-log-bundle", 3 | "type": "symfony-bundle", 4 | "description": "Activity log bundle - Extended doctrine loggable bundle", 5 | "keywords": ["bundle", "doctrine", "loggable", "activity", "symfony"], 6 | "homepage": "https://github.com/madmis/ActivityLogBundle.git", 7 | "authors": [ 8 | { 9 | "name": "Dmitry Machin", 10 | "email": "madmis@inbox.ru" 11 | } 12 | ], 13 | "license": "MIT", 14 | "require": { 15 | "stof/doctrine-extensions-bundle": "^1.4" 16 | }, 17 | "autoload": { 18 | "psr-4": { 19 | "ActivityLogBundle\\": "" 20 | } 21 | }, 22 | "minimum-stability": "dev", 23 | "extra": { 24 | "branch-alias": { 25 | "dev-master": "1.x-dev" 26 | } 27 | }, 28 | "require-dev": { 29 | "phpunit/phpunit": "^5.3" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | ./Tests 16 | 17 | 18 | 19 | 20 | 21 | ./ 22 | 23 | ActivityLogBundle.php 24 | ./Tests 25 | ./Resources 26 | ./vendor 27 | ./DependencyInjection 28 | ./Entity/Interfaces 29 | ./Entity/LogEntryInterface.php 30 | ./Service/ActivityLog/EntityFormatter/FormatterInterface.php 31 | 32 | 33 | 34 | 35 | --------------------------------------------------------------------------------