Parcourir la source

Add version lock

Emmanuel Vella il y a 9 ans
Parent
commit
0b8f5ea760

+ 85 - 0
Admin/Extension/LockExtension.php

@@ -0,0 +1,85 @@
+<?php
+
+/*
+ * This file is part of the Sonata Project package.
+ *
+ * (c) Thomas Rabaix <thomas.rabaix@sonata-project.org>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Sonata\AdminBundle\Admin\Extension;
+
+use Sonata\AdminBundle\Admin\AdminExtension;
+use Sonata\AdminBundle\Admin\AdminInterface;
+use Sonata\AdminBundle\Form\FormMapper;
+use Sonata\AdminBundle\Model\LockInterface;
+use Symfony\Component\Form\FormEvent;
+use Symfony\Component\Form\FormEvents;
+
+/**
+ * @author Emmanuel Vella <vella.emmanuel@gmail.com>
+ */
+class LockExtension extends AdminExtension
+{
+    protected $fieldName = '_lock_version';
+
+    /**
+     * {@inheritdoc}
+     */
+    public function configureFormFields(FormMapper $form)
+    {
+        $admin = $form->getAdmin();
+        $formBuilder = $form->getFormBuilder();
+
+        // PHP 5.3 BC
+        $fieldName = $this->fieldName;
+
+        $formBuilder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($admin, $fieldName) {
+            $data = $event->getData();
+            $form = $event->getForm();
+
+            if (null === $data || $form->getParent()) {
+                return;
+            }
+
+            $modelManager = $admin->getModelManager();
+
+            if (!$modelManager instanceof LockInterface) {
+                return;
+            }
+
+            if (null === $lockVersion = $modelManager->getLockVersion($data)) {
+                return;
+            }
+
+            $form->add($fieldName, 'hidden', array(
+                'mapped' => false,
+                'data'   => $lockVersion,
+            ));
+        });
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function preUpdate(AdminInterface $admin, $object)
+    {
+        if (!$admin->hasRequest() || !$data = $admin->getRequest()->get($admin->getUniqid())) {
+            return;
+        }
+
+        if (!isset($data[$this->fieldName])) {
+            return;
+        }
+
+        $modelManager = $admin->getModelManager();
+
+        if (!$modelManager instanceof LockInterface) {
+            return;
+        }
+
+        $modelManager->lock($object, $data[$this->fieldName]);
+    }
+}

+ 7 - 0
Controller/CRUDController.php

@@ -15,6 +15,7 @@ use Psr\Log\NullLogger;
 use Sonata\AdminBundle\Admin\AdminInterface;
 use Sonata\AdminBundle\Admin\BaseFieldDescription;
 use Sonata\AdminBundle\Datagrid\ProxyQueryInterface;
+use Sonata\AdminBundle\Exception\LockException;
 use Sonata\AdminBundle\Exception\ModelManagerException;
 use Sonata\AdminBundle\Util\AdminObjectAclData;
 use Sonata\AdminBundle\Util\AdminObjectAclManipulator;
@@ -436,6 +437,12 @@ class CRUDController extends Controller
                     $this->handleModelManagerException($e);
 
                     $isFormValid = false;
+                } catch (LockException $e) {
+                    $this->addFlash('sonata_flash_error', $this->admin->trans('flash_lock_error', array(
+                        '%name%'       => $this->escapeHtml($this->admin->toString($object)),
+                        '%link_start%' => '<a href="'.$this->admin->generateObjectUrl('edit', $object).'">',
+                        '%link_end%'   => '</a>',
+                    ), 'SonataAdminBundle'));
                 }
             }
 

+ 4 - 0
DependencyInjection/Configuration.php

@@ -87,6 +87,10 @@ class Configuration implements ConfigurationInterface
                             ->defaultValue('both')
                             ->cannotBeEmpty()
                         ->end()
+                        ->booleanNode('lock_protection')
+                            ->defaultFalse()
+                            ->info('Enable locking when editing an object, if the corresponding object manager supports it.')
+                        ->end()
                     ->end()
                 ->end()
                 ->arrayNode('dashboard')

+ 4 - 0
DependencyInjection/SonataAdminExtension.php

@@ -93,6 +93,10 @@ BOOM
         $pool->replaceArgument(2, $config['title_logo']);
         $pool->replaceArgument(3, $config['options']);
 
+        if (false === $config['options']['lock_protection']) {
+            $container->removeDefinition('sonata.admin.lock.extension');
+        }
+
         $container->setParameter('sonata.admin.configuration.templates', $config['templates']);
         $container->setParameter('sonata.admin.configuration.admin_services', $config['admin_services']);
         $container->setParameter('sonata.admin.configuration.dashboard_groups', $config['dashboard']['groups']);

+ 19 - 0
Exception/LockException.php

@@ -0,0 +1,19 @@
+<?php
+
+/*
+ * This file is part of the Sonata Project package.
+ *
+ * (c) Thomas Rabaix <thomas.rabaix@sonata-project.org>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Sonata\AdminBundle\Exception;
+
+/**
+ * @author Emmanuel Vella <vella.emmanuel@gmail.com>
+ */
+class LockException extends \Exception
+{
+}

+ 35 - 0
Model/LockInterface.php

@@ -0,0 +1,35 @@
+<?php
+
+/*
+ * This file is part of the Sonata Project package.
+ *
+ * (c) Thomas Rabaix <thomas.rabaix@sonata-project.org>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Sonata\AdminBundle\Model;
+
+use Sonata\AdminBundle\Exception\LockException;
+
+/**
+ * @author Emmanuel Vella <vella.emmanuel@gmail.com>
+ */
+interface LockInterface
+{
+    /**
+     * @param object $object
+     *
+     * @return mixed|null
+     */
+    public function getLockVersion($object);
+
+    /**
+     * @param object $object
+     * @param mixed  $expectedVersion
+     *
+     * @throws LockException
+     */
+    public function lock($object, $expectedVersion);
+}

+ 5 - 0
Resources/config/core.xml

@@ -72,6 +72,11 @@
             <tag name="sonata.admin.extension" global="true" />
         </service>
 
+        <!-- lock -->
+        <service id="sonata.admin.lock.extension" class="Sonata\AdminBundle\Admin\Extension\LockExtension">
+            <tag name="sonata.admin.extension" global="true" />
+        </service>
+
         <!-- twig -->
         <service id="sonata.admin.twig.global" class="Sonata\AdminBundle\Twig\GlobalVariables" >
             <argument type="service" id="service_container" />

+ 33 - 0
Resources/doc/cookbook/recipe_lock_protection.rst

@@ -0,0 +1,33 @@
+Lock protection
+==========================
+
+Lock protection will prevent data corruption when multiple users edit an object at the same time.
+
+Example
+^^^^^^^
+
+1) Alice starts to edit the object
+2) Bob starts to edit the object
+3) Alice submits the form
+4) Bob submits the form
+
+In this case, a message will tell Bob that someone else has edited the object,
+and that he must reload the page and apply the changes again.
+
+Enable lock protection
+----------------------
+
+By default, lock protection is disabled. You can enable it in your ``sonata_admin`` configuration :
+
+.. configuration-block::
+
+    .. code-block:: yaml
+
+        sonata_admin:
+            options:
+                lock_protection: true
+
+.. note::
+    If the object model manager does not supports object locking,
+    the lock protection will not be triggered for the object.
+    Currently, only the ``SonataDoctrineORMAdminBundle`` supports it.

+ 1 - 0
Resources/doc/index.rst

@@ -74,3 +74,4 @@ Cookbook
    cookbook/recipe_improve_performance_large_datasets
    cookbook/recipe_virtual_field
    cookbook/recipe_bootlint
+   cookbook/recipe_lock_protection

+ 1 - 0
Resources/doc/reference/configuration.rst

@@ -65,6 +65,7 @@ Full Configuration Options
                 form_type:            standard
                 dropdown_number_groups_per_colums:  2
                 title_mode:           ~ # One of "single_text"; "single_image"; "both"
+                lock_protection:      false
             dashboard:
                 groups:
 

+ 4 - 0
Resources/translations/SonataAdminBundle.en.xliff

@@ -162,6 +162,10 @@
               <source>flash_edit_error</source>
               <target>An error has occurred during update of item "%name%".</target>
             </trans-unit>
+            <trans-unit id='flash_lock_error'>
+                <source>flash_lock_error</source>
+                <target>Another user has modified item "%name%". Please %link_start%click here%link_end% to reload the page and apply the changes again.</target>
+            </trans-unit>
             <trans-unit id='flash_batch_delete_success'>
                 <source>flash_batch_delete_success</source>
                 <target>Selected items have been successfully deleted.</target>

+ 4 - 0
Resources/translations/SonataAdminBundle.fr.xliff

@@ -162,6 +162,10 @@
                 <source>flash_edit_error</source>
                 <target>Une erreur est intervenue lors de la mise à jour de l'élément "%name%".</target>
             </trans-unit>
+            <trans-unit id='flash_lock_error'>
+                <source>flash_lock_error</source>
+                <target>Un autre utilisateur a modifié l'élément "%name%". Veuillez %link_start%cliquer ici%link_end% pour recharger la page et appliquer les changements à nouveau.</target>
+            </trans-unit>
             <trans-unit id='flash_batch_delete_success'>
                 <source>flash_batch_delete_success</source>
                 <target>Les éléments séléctionnés ont été supprimés avec succès.</target>

+ 137 - 0
Tests/Admin/Extension/LockExtensionTest.php

@@ -0,0 +1,137 @@
+<?php
+
+/*
+ * This file is part of the Sonata Project package.
+ *
+ * (c) Thomas Rabaix <thomas.rabaix@sonata-project.org>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Sonata\AdminBundle\Tests\Admin\Extension;
+
+use Sonata\AdminBundle\Admin\Extension\LockExtension;
+use Sonata\AdminBundle\Form\FormMapper;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Symfony\Component\Form\FormBuilder;
+use Symfony\Component\Form\FormEvent;
+use Symfony\Component\Form\FormEvents;
+use Symfony\Component\HttpFoundation\Request;
+
+class LockExtensionTest extends \PHPUnit_Framework_TestCase
+{
+    /**
+     * @var LockExtension
+     */
+    private $lockExtension;
+
+    /**
+     * @var EventDispatcherInterface
+     */
+    private $eventDispatcher;
+
+    private $admin;
+
+    private $modelManager;
+
+    private $form;
+
+    private $object;
+
+    private $request;
+
+    public function setUp()
+    {
+        $contractor = $this->getMock('Sonata\AdminBundle\Builder\FormContractorInterface');
+
+        $formFactory = $this->getMock('Symfony\Component\Form\FormFactoryInterface');
+        $this->eventDispatcher = new EventDispatcher();
+
+        $formBuilder = new FormBuilder('form', null, $this->eventDispatcher, $formFactory);
+
+        $this->modelManager = $this->getMock('Sonata\AdminBundle\Model\LockInterface');
+
+        $this->admin = $this->getMockBuilder('Sonata\AdminBundle\Admin\Admin')
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $this->admin->expects($this->any())
+            ->method('getModelManager')
+            ->will($this->returnValue($this->modelManager));
+
+        $this->request = new Request();
+
+        $this->admin->expects($this->any())
+            ->method('getRequest')
+            ->will($this->returnValue($this->request));
+
+        $this->admin->expects($this->any())
+            ->method('hasRequest')
+            ->will($this->returnValue(true));
+
+        $formMapper = new FormMapper(
+            $contractor,
+            $formBuilder,
+            $this->admin
+        );
+
+        $this->object = new \StdClass();
+
+        $this->form = $this->getMock('Symfony\Component\Form\FormInterface');
+
+        $this->form->expects($this->any())
+            ->method('getData')
+            ->will($this->returnValue($this->object));
+
+        $this->lockExtension = new LockExtension();
+        $this->lockExtension->configureFormFields($formMapper);
+    }
+
+    public function testConfigureFormFields()
+    {
+        $this->modelManager->expects($this->any())
+            ->method('getLockVersion')
+            ->will($this->returnValue(1));
+
+        $this->form->expects($this->once())
+            ->method('add')
+            ->with(
+                $this->equalTo('_lock_version'),
+                $this->equalTo('hidden'),
+                $this->equalTo(array(
+                    'mapped' => false,
+                    'data'   => 1,
+                ))
+            );
+
+        $event = new FormEvent($this->form, array());
+        $this->eventDispatcher->dispatch(FormEvents::PRE_SET_DATA, $event);
+    }
+
+    public function testPreUpdateIfObjectIsNotVersioned()
+    {
+        $this->modelManager->expects($this->never())
+            ->method('lock');
+
+        $this->lockExtension->preUpdate($this->admin, $this->object);
+    }
+
+    public function testPreUpdateIfObjectIsVersioned()
+    {
+        $uniqid = 'admin123';
+
+        $this->modelManager->expects($this->once())
+            ->method('lock')
+            ->with($this->object, 1);
+
+        $this->request->request->set($uniqid, array('_lock_version' => 1));
+
+        $this->admin->expects($this->any())
+            ->method('getUniqId')
+            ->will($this->returnValue($uniqid));
+
+        $this->lockExtension->preUpdate($this->admin, $this->object);
+    }
+}

+ 60 - 0
Tests/Controller/CRUDControllerTest.php

@@ -15,6 +15,7 @@ use Sonata\AdminBundle\Admin\AdminInterface;
 use Sonata\AdminBundle\Admin\FieldDescriptionCollection;
 use Sonata\AdminBundle\Admin\Pool;
 use Sonata\AdminBundle\Controller\CRUDController;
+use Sonata\AdminBundle\Exception\LockException;
 use Sonata\AdminBundle\Exception\ModelManagerException;
 use Sonata\AdminBundle\Tests\Fixtures\Controller\BatchAdminController;
 use Sonata\AdminBundle\Tests\Fixtures\Controller\PreCRUDController;
@@ -1725,6 +1726,65 @@ class CRUDControllerTest extends \PHPUnit_Framework_TestCase
         $this->assertEquals('SonataAdminBundle:CRUD:preview.html.twig', $this->template);
     }
 
+    public function testEditActionWithLockException()
+    {
+        $object = new \stdClass();
+        $class = get_class($object);
+
+        $this->admin->expects($this->any())
+            ->method('getObject')
+            ->will($this->returnValue($object));
+
+        $this->admin->expects($this->any())
+            ->method('isGranted')
+            ->with($this->equalTo('EDIT'))
+            ->will($this->returnValue(true));
+
+        $this->admin->expects($this->any())
+            ->method('getClass')
+            ->will($this->returnValue($class));
+
+        $form = $this->getMockBuilder('Symfony\Component\Form\Form')
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $form->expects($this->any())
+            ->method('isValid')
+            ->will($this->returnValue(true));
+
+        $this->admin->expects($this->any())
+            ->method('getForm')
+            ->will($this->returnValue($form));
+
+        $form->expects($this->any())
+            ->method('isSubmitted')
+            ->will($this->returnValue(true));
+        $this->request->setMethod('POST');
+
+        $this->admin->expects($this->any())
+            ->method('update')
+            ->will($this->throwException(new LockException()));
+
+        $this->admin->expects($this->any())
+            ->method('toString')
+            ->with($this->equalTo($object))
+            ->will($this->returnValue($class));
+
+        $formView = $this->getMock('Symfony\Component\Form\FormView');
+
+        $form->expects($this->any())
+            ->method('createView')
+            ->will($this->returnValue($formView));
+
+        $this->expectTranslate('flash_lock_error', array(
+            '%name%'       => $class,
+            '%link_start%' => '<a href="stdClass_edit">',
+            '%link_end%'   => '</a>',
+        ), 'SonataAdminBundle');
+
+        $this->assertInstanceOf('Symfony\Component\HttpFoundation\Response', $this->controller->editAction(null, $this->request));
+    }
+
     public function testCreateActionAccessDenied()
     {
         $this->setExpectedException('Symfony\Component\Security\Core\Exception\AccessDeniedException');