Symfony 2 Joboard : Интерфейс администратора

После завершения 11 части наше приложение вполне работоспособно. Им могут пользоваться как соискатели, так работодатели. Настало время обсудить администраторскую составляющую нашего сайта. При помощи Sonata Admin Bundle мы полностью реализуем интерфейс администратора менее чем за час.

Установка Sonata Admin Bundle

Чтобы установить последнюю версию бандла, добавьте в файл composer.json следующие строки:

"sonata-project/admin-bundle": "dev-master",
"sonata-project/doctrine-orm-admin-bundle": "dev-master"

Затем запустите команду обновления пакетов composer:

php composer.phar update

Теперь укажем в файле AppKernel.php новые бандлы:

<?php
# app/AppKernel.php
// ...
    public function registerBundles()
    {
        $bundles = array(
            // ...
            new Sonata\CoreBundle\SonataCoreBundle(),
            new Sonata\AdminBundle\SonataAdminBundle(),
            new Sonata\BlockBundle\SonataBlockBundle(),
            new Sonata\DoctrineORMAdminBundle\SonataDoctrineORMAdminBundle(),
            new Knp\Bundle\MenuBundle\KnpMenuBundle(),
        );
    }
// ...

Хотя мы и указали в composer.json всего два бандла для установки, но на самом деле установилось ещё несколько: MenuBundle, SonataBlockBundle, SonataCoreBundle. Эти бандлы необходимы для правильного функционирования административной части и являются зависимостями для admin-bundle и doctrine-orm-admin-bundle. Эти зависимости прописаны в их собственных файлах composer.json.

Так же следует внести изменения в файл настроек config.yml, добавьте в конец новые настройки:

# app/config/config.yml
# ...
sonata_admin:
   title: Joboard Admin
sonata_block:
   default_contexts: [cms]
   blocks:
       sonata.admin.block.admin_list:
           contexts:   [admin]
       sonata.block.service.text:
       sonata.block.service.action:
       sonata.block.service.rss:

Раскомментируйте директиву translator:

# app/config/config.yml
# ...
framework:
   # ...
   translator: { fallback: "%locale%"}
   # ...
#...

Импортируем маршруты администраторской части в общий файл маршрутизации routing.yml:

# app/config/routing.yml
admin:
   resource: '@SonataAdminBundle/Resources/config/routing/sonata_admin.xml'
   prefix: /admin
_sonata_admin:
   resource: .
   type: sonata_admin
   prefix: /admin
# ...

Добавим роль ROLE_SONATA_ADMIN в app/config/security.yml:

security:
   role_hierarchy:
       ROLE_ADMIN:       [ROLE_USER, ROLE_SONATA_ADMIN]
       ROLE_SUPER_ADMIN: [ROLE_USER, ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]

Установим статические файлы бандла:

php app/console assets:install web --symlink

Не забудьте очистить кеш:

php app/console cache:clear --env=dev
php app/console cache:clear --env=prod

Администраторская часть сайта теперь доступна по адресу: http://joboard.local/app_dev.php/admin/dashboard

CRUD контроллер

Этот контроллер содержит в себе базовые методы для управления моделями(удаление, правка и создание). Один класс Admin соответствует одной сущности вашего приложения. Все действия контроллера могут быть переписаны в зависимости от требований приложения. Контроллер использует класс Admin для создания различных действий. Внутри контроллера объект типа Admin можно получить через свойства настроек.

Создадим контроллеры для каждой модели. Сначала для Category:

<?php
# src/App/JoboardBundle/Controller/CategoryAdminController.php
namespace App\JoboardBundle\Controller;
use Sonata\AdminBundle\Controller\CRUDController as Controller;
class CategoryAdminController extends Controller
{
    // Здесь будет ваш код
}

Теперь для Job:

<?php
# src/App/JoboardBundle/Controller/JobAdminController.php
namespace App\JoboardBundle\Controller;
use Sonata\AdminBundle\Controller\CRUDController as Controller;
class JobAdminController extends Controller
{
    // Здесь будет ваш код
}

Создание класса Admin

Класс Admin отвечает за настройку отображения модели в администраторской части. Самый простой способ — унаследовать класс Sonata\AdminBundle\Admin\Admin. Создадим классы Admin в каталоге Adminнашего бандла.

Сначала создадим каталог, а затем класс для категорий:

<?php
# src/App/JoboardBundle/Admin/CategoryAdmin.php
namespace App\JoboardBundle\Admin;
use Sonata\AdminBundle\Admin\Admin;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Validator\ErrorElement;
use Sonata\AdminBundle\Form\FormMapper;
class CategoryAdmin extends Admin
{
    // Здесь будет ваш код
}

Теперь для вакансий:

<?php
# src/App/JoboardBundle/Admin/JobAdmin.php
namespace App\JoboardBundle\Admin;
use Sonata\AdminBundle\Admin\Admin;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Validator\ErrorElement;
use Sonata\AdminBundle\Form\FormMapper;
use Sonata\AdminBundle\Show\ShowMapper;
use App\JoboardBundle\Entity\Job;
class JobAdmin extends Admin
{
    // Здесь будет ваш код
}

Теперь добавим эти классы в файл настроек служб services.yml:

# src/App/JoboardBundle/Resources/config/services.yml
services:
   app.joboard.admin.category:
       class: App\JoboardBundle\Admin\CategoryAdmin
       tags:
           - { name: sonata.admin, manager_type: orm, group: joboard, label: "Категории" }
       arguments:
           - ~
           - App\JoboardBundle\Entity\Category
           - 'AppJoboardBundle:CategoryAdmin'
   app.joboard.admin.job:
       class: App\JoboardBundle\Admin\JobAdmin
       tags:
           - { name: sonata.admin, manager_type: orm, group: joboard, label: "Вакансии" }
       arguments:
           - ~
           - App\JoboardBundle\Entity\Job
           - 'AppJoboardBundle:JobAdmin'

На этом этапе мы видим группу Joboard, включающую в себя модули Job и Category со сылками Add new (добавить) и List (список).

Настройки классов Admin

Если вы перейдете по этим ссылкам, то ничего полезного там не найдете, потому что мы не задали поля для отображения. Выполним базовую настройку для категорий:

<?php
# src/App/JoboardBundle/Admin/CategoryAdmin.php
namespace App\JoboardBundle\Admin;
use Sonata\AdminBundle\Admin\Admin;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Validator\ErrorElement;
use Sonata\AdminBundle\Form\FormMapper;
class CategoryAdmin extends Admin
{
    // установка сортировки по умолчанию
    protected $datagridValues = [
        '_sort_order' => 'ASC',
        '_sort_by'    => 'name'
    ];
    protected function configureFormFields(FormMapper $formMapper)
    {
        $formMapper
            ->add('name')
            ->add('slug')
        ;
    }
    protected function configureDatagridFilters(DatagridMapper $datagridMapper)
    {
        $datagridMapper
            ->add('name')
        ;
    }
    protected function configureListFields(ListMapper $listMapper)
    {
        $listMapper
            ->addIdentifier('name')
            ->add('slug')
        ;
    }
}

Теперь для вакансий:

<?php
namespace App\JoboardBundle\Admin;
use Sonata\AdminBundle\Admin\Admin;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Validator\ErrorElement;
use Sonata\AdminBundle\Form\FormMapper;
use Sonata\AdminBundle\Show\ShowMapper;
use App\JoboardBundle\Entity\Job;
class JobAdmin extends Admin
{
    // установка сортировки по умолчанию
    protected $datagridValues = [
        '_sort_order' => 'DESC',
        '_sort_by' => 'created_at'
    ];
    protected function configureFormFields(FormMapper $formMapper)
    {
        $formMapper
            ->add('category')
            ->add('type', 'choice', ['choices' => Job::getTypes(), 'expanded' => true])
            ->add('company')
            ->add('file', 'file', ['label' => 'Лого компании', 'required' => false])
            ->add('url')
            ->add('position')
            ->add('location')
            ->add('description')
            ->add('how_to_apply')
            ->add('is_public')
            ->add('email')
            ->add('is_activated')
        ;
    }
    protected function configureDatagridFilters(DatagridMapper $datagridMapper)
    {
        $datagridMapper
        ->add('category')
        ->add('company')
        ->add('position')
        ->add('description')
        ->add('is_activated')
        ->add('is_public')
        ->add('email')
        ->add('expires_at')
        ;
    }
    protected function configureListFields(ListMapper $listMapper)
    {
        $listMapper
            ->addIdentifier('company')
            ->add('position')
            ->add('location')
            ->add('url')
            ->add('is_activated')
            ->add('email')
            ->add('category')
            ->add('expires_at')
            ->add('_action', 'actions', [
                'actions' => [
                  'view' => [],
                  'edit' => [],
                  'delete' => [],
                ]
            ])
        ;
    }
    protected function configureShowFields(ShowMapper $showMapper)
    {
        $showMapper
            ->add('category')
            ->add('type')
            ->add('company')
            ->add('webPath', 'string', ['template' => 'AppJoboardBundle:JobAdmin:list_image.html.twig'])
            ->add('url')
            ->add('position')
            ->add('location')
            ->add('description')
            ->add('how_to_apply')
            ->add('is_public')
            ->add('is_activated')
            ->add('token')
            ->add('email')
            ->add('expires_at')
        ;
    }
}

Для метода show мы используем свой шаблон, чтобы отобразить логотип компании — создайте новый файл src/App/JoboardBundle/Resources/views/JobAdmin/list_image.html.twig и вставьте следующий html код:

<tr>
   <th>Логотип</th>
   <td><img src="{{ asset(object.webPath) }}"></td>
</tr>

Итак, мы создали базовый модуль администрирования сайта. Вот список его функциональных возможностей:

  • Список объектов разбитый по страницам
  • Сортировка списка
  • Фильтрование списка
  • Объекты можно удалять, править и добавлять
  • Удалять объекты можно группами
  • Активирована проверка форм
  • Всплывающие сообщения, при работе, выполняют роль обратной связи

Групповые действия

Такие действия выполняются над группой объектов. Вы можете добавлять свои действия. По-умолчанию доступно удаление моделей. Чтобы добавить новый метод следует переписать getBatchActions в классе Admin. Добавим новый метод — extend().

<?php
# src/App/JoboardBundle/Admin/JobAdmin.php
// ...
    public function getBatchActions()
    {
        $actions = parent::getBatchActions();
        // проверка прав пользователя
        if($this->hasRoute('edit') && $this->isGranted('EDIT') && $this->hasRoute('delete') && $this->isGranted('DELETE')) {
        $actions['extend'] = [
                'label'            => 'Продлить',
                'ask_confirmation' => true // Если true, будет выведено сообщение о подтверждении действия
            ];
        }
        return $actions;
    }

Метод batchActionExtend из JobAdminController отвечает за выполнение основой логики действия. Выбранные объекты передаются через параметр query. Если по каким-либо причинам вам не требуется получение выбранных объектов (например, вы используете другой способ выбора на уровне шаблона), то этот параметр будет равен null.

<?php
# src/App/JoboardBundle/Controller/JobAdminController.php
namespace App\JoboardBundle\Controller;
use Sonata\AdminBundle\Controller\CRUDController as Controller;
use Sonata\DoctrineORMAdminBundle\Datagrid\ProxyQuery as ProxyQueryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
class JobAdminController extends Controller
{
    public function batchActionExtend(ProxyQueryInterface $selectedModelQuery)
    {
        if ($this->admin->isGranted('EDIT') === false || $this->admin->isGranted('DELETE') === false) {
            throw new AccessDeniedException();
        }
        $modelManager = $this->admin->getModelManager();
        $selectedModels = $selectedModelQuery->execute();
        try {
            foreach ($selectedModels as $selectedModel) {
                $selectedModel->extend();
                $modelManager->update($selectedModel);
            }
        } catch (\Exception $e) {
            $this->get('session')->getFlashBag()->add('sonata_flash_error', $e->getMessage());
            return new RedirectResponse($this->admin->generateUrl('list',$this->admin->getFilterParameters()));
        }
        $this->get('session')->getFlashBag()->add('sonata_flash_success',  sprintf('Выбранные вакансии продлены до %s.', date('m/d/Y', time() + 86400 * 30)));
        return new RedirectResponse($this->admin->generateUrl('list', $this->admin->getFilterParameters()));
    }
}

Добавим новый метод, который будет удалять все вакансии, не активированные за последние 60 дней. Для этого нам не надо выбирать никаких объектов, так как мы их будем искать в теле метода.

<?php
# src/App/JoboardBundle/Admin/JobAdmin.php
// ...
    public function getBatchActions()
    {
        $actions = parent::getBatchActions();
        // проверка прав пользователя
        if($this->hasRoute('edit') && $this->isGranted('EDIT') && $this->hasRoute('delete') && $this->isGranted('DELETE')) {
            $actions['extend'] = [
            'label'            => 'Продлить',
            'ask_confirmation' => true // Если true, будет выведено сообщение о подтверждении действия
            ];
        }
        $actions['deleteNeverActivated'] = [
            'label'            => 'Удалить просроченные вакансии',
            'ask_confirmation' => true // Если true, будет выведено сообщение о подтверждении действия
        ];
        return $actions;
    }

Для создания действия batchActionDeleteNeverActivated, создадим новый метод batchActionDeleteNeverActivatedIsRelevant в JobAdminController. Он будет выполняться каждый раз до внесения каких-либо изменений, чтобы получить подтверждение на исполнение действия (в нашем случае он всегда будет возвращать true, так как выборка вакансий происходит в теле метода JobRepository::cleanup()).

<?php
# src/App/JoboardBundle/Controller/JobAdminController.php
// ...
    public function batchActionDeleteNeverActivatedIsRelevant()
    {
        return true;
    }
    public function batchActionDeleteNeverActivated()
    {
        if ($this->admin->isGranted('EDIT') === false || $this->admin->isGranted('DELETE') === false) {
            throw new AccessDeniedException();
        }
        $em = $this->getDoctrine()->getManager();
        $nb = $em->getRepository('AppJoboardBundle:Job')->cleanup(60);
        if ($nb) {
            $this->get('session')->getFlashBag()->add('sonata_flash_success', sprintf('%d просроченные вакансии успешно удалены.', $nb));
            } else {
            $this->get('session')->getFlashBag()->add('sonata_flash_info', 'Нет вакансий для удаления.');
        }
        return new RedirectResponse($this->admin->generateUrl('list', $this->admin->getFilterParameters()));
    }

Вот что у нас получилось:

На сегодня это все. В следующей части рассмотрим, как защитить администраторскую часть. Заодно обсудим систему безопасности Symfony2.