<?php
declare(strict_types=1);
/*
* 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\Controller;
use Doctrine\Inflector\InflectorFactory;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Sonata\AdminBundle\Admin\AdminInterface;
use Sonata\AdminBundle\Admin\Pool;
use Sonata\AdminBundle\Bridge\Exporter\AdminExporter;
use Sonata\AdminBundle\Datagrid\ProxyQueryInterface;
use Sonata\AdminBundle\Exception\BadRequestParamHttpException;
use Sonata\AdminBundle\Exception\LockException;
use Sonata\AdminBundle\Exception\ModelManagerException;
use Sonata\AdminBundle\Exception\ModelManagerThrowable;
use Sonata\AdminBundle\Form\FormErrorIteratorToConstraintViolationList;
use Sonata\AdminBundle\Model\AuditManagerInterface;
use Sonata\AdminBundle\Request\AdminFetcherInterface;
use Sonata\AdminBundle\Templating\TemplateRegistryInterface;
use Sonata\AdminBundle\Util\AdminAclUserManagerInterface;
use Sonata\AdminBundle\Util\AdminObjectAclData;
use Sonata\AdminBundle\Util\AdminObjectAclManipulator;
use Sonata\Exporter\Exporter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormRenderer;
use Symfony\Component\Form\FormView;
use Symfony\Component\HttpFoundation\InputBag;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyPath;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
/**
* @author Thomas Rabaix <thomas.rabaix@sonata-project.org>
*
* @phpstan-template T of object
*
* @psalm-suppress MissingConstructor
*
* @see ConfigureCRUDControllerListener
*/
class CRUDController extends AbstractController
{
/**
* The related Admin class.
*
* @var AdminInterface<object>
*
* @phpstan-var AdminInterface<T>
*
* @psalm-suppress PropertyNotSetInConstructor
*/
protected $admin;
/**
* The template registry of the related Admin class.
*
* @psalm-suppress PropertyNotSetInConstructor
* @phpstan-ignore-next-line
*/
private TemplateRegistryInterface $templateRegistry;
public static function getSubscribedServices(): array
{
return [
'sonata.admin.pool' => Pool::class,
'sonata.admin.audit.manager' => AuditManagerInterface::class,
'sonata.admin.object.manipulator.acl.admin' => AdminObjectAclManipulator::class,
'sonata.admin.request.fetcher' => AdminFetcherInterface::class,
'sonata.exporter.exporter' => '?'.Exporter::class,
'sonata.admin.admin_exporter' => '?'.AdminExporter::class,
'sonata.admin.security.acl_user_manager' => '?'.AdminAclUserManagerInterface::class,
'controller_resolver' => 'controller_resolver',
'http_kernel' => HttpKernelInterface::class,
'logger' => '?'.LoggerInterface::class,
'translator' => TranslatorInterface::class,
] + parent::getSubscribedServices();
}
/**
* @throws AccessDeniedException If access is not granted
*/
public function listAction(Request $request): Response
{
$this->assertObjectExists($request);
$this->admin->checkAccess('list');
$preResponse = $this->preList($request);
if (null !== $preResponse) {
return $preResponse;
}
$listMode = $request->get('_list_mode');
if (\is_string($listMode)) {
$this->admin->setListMode($listMode);
}
$datagrid = $this->admin->getDatagrid();
$formView = $datagrid->getForm()->createView();
// set the theme for the current Admin Form
$this->setFormTheme($formView, $this->admin->getFilterTheme());
$template = $this->templateRegistry->getTemplate('list');
if ($this->container->has('sonata.admin.admin_exporter')) {
$exporter = $this->container->get('sonata.admin.admin_exporter');
\assert($exporter instanceof AdminExporter);
$exportFormats = $exporter->getAvailableFormats($this->admin);
}
return $this->renderWithExtraParams($template, [
'action' => 'list',
'form' => $formView,
'datagrid' => $datagrid,
'csrf_token' => $this->getCsrfToken('sonata.batch'),
'export_formats' => $exportFormats ?? $this->admin->getExportFormats(),
]);
}
/**
* NEXT_MAJOR: Change signature to `(ProxyQueryInterface $query, Request $request).
*
* Execute a batch delete.
*
* @throws AccessDeniedException If access is not granted
*
* @phpstan-param ProxyQueryInterface<T> $query
*/
public function batchActionDelete(ProxyQueryInterface $query): Response
{
$this->admin->checkAccess('batchDelete');
$modelManager = $this->admin->getModelManager();
try {
$modelManager->batchDelete($this->admin->getClass(), $query);
$this->addFlash(
'sonata_flash_success',
$this->trans('flash_batch_delete_success', [], 'SonataAdminBundle')
);
} catch (ModelManagerException $e) {
// NEXT_MAJOR: Remove this catch.
$this->handleModelManagerException($e);
$this->addFlash(
'sonata_flash_error',
$this->trans('flash_batch_delete_error', [], 'SonataAdminBundle')
);
} catch (ModelManagerThrowable $e) {
$errorMessage = $this->handleModelManagerThrowable($e);
$this->addFlash(
'sonata_flash_error',
$errorMessage ?? $this->trans('flash_batch_delete_error', [], 'SonataAdminBundle')
);
}
return $this->redirectToList();
}
/**
* @throws NotFoundHttpException If the object does not exist
* @throws AccessDeniedException If access is not granted
*/
public function deleteAction(Request $request): Response
{
$object = $this->assertObjectExists($request, true);
\assert(null !== $object);
$this->checkParentChildAssociation($request, $object);
$this->admin->checkAccess('delete', $object);
$preResponse = $this->preDelete($request, $object);
if (null !== $preResponse) {
return $preResponse;
}
if (\in_array($request->getMethod(), [Request::METHOD_POST, Request::METHOD_DELETE], true)) {
// check the csrf token
$this->validateCsrfToken($request, 'sonata.delete');
$objectName = $this->admin->toString($object);
try {
$this->admin->delete($object);
if ($this->isXmlHttpRequest($request)) {
return $this->renderJson(['result' => 'ok']);
}
$this->addFlash(
'sonata_flash_success',
$this->trans(
'flash_delete_success',
['%name%' => $this->escapeHtml($objectName)],
'SonataAdminBundle'
)
);
} catch (ModelManagerException $e) {
// NEXT_MAJOR: Remove this catch.
$this->handleModelManagerException($e);
if ($this->isXmlHttpRequest($request)) {
return $this->renderJson(['result' => 'error']);
}
$this->addFlash(
'sonata_flash_error',
$this->trans(
'flash_delete_error',
['%name%' => $this->escapeHtml($objectName)],
'SonataAdminBundle'
)
);
} catch (ModelManagerThrowable $e) {
$errorMessage = $this->handleModelManagerThrowable($e);
if ($this->isXmlHttpRequest($request)) {
return $this->renderJson(['result' => 'error'], Response::HTTP_OK, []);
}
$this->addFlash(
'sonata_flash_error',
$errorMessage ?? $this->trans(
'flash_delete_error',
['%name%' => $this->escapeHtml($objectName)],
'SonataAdminBundle'
)
);
}
return $this->redirectTo($request, $object);
}
$template = $this->templateRegistry->getTemplate('delete');
return $this->renderWithExtraParams($template, [
'object' => $object,
'action' => 'delete',
'csrf_token' => $this->getCsrfToken('sonata.delete'),
]);
}
/**
* @throws NotFoundHttpException If the object does not exist
* @throws AccessDeniedException If access is not granted
*/
public function editAction(Request $request): Response
{
// the key used to lookup the template
$templateKey = 'edit';
$existingObject = $this->assertObjectExists($request, true);
\assert(null !== $existingObject);
$this->checkParentChildAssociation($request, $existingObject);
$this->admin->checkAccess('edit', $existingObject);
$preResponse = $this->preEdit($request, $existingObject);
if (null !== $preResponse) {
return $preResponse;
}
$this->admin->setSubject($existingObject);
$objectId = $this->admin->getNormalizedIdentifier($existingObject);
\assert(null !== $objectId);
$form = $this->admin->getForm();
$form->setData($existingObject);
$form->handleRequest($request);
if ($form->isSubmitted()) {
$isFormValid = $form->isValid();
// persist if the form was valid and if in preview mode the preview was approved
if ($isFormValid && (!$this->isInPreviewMode($request) || $this->isPreviewApproved($request))) {
/** @phpstan-var T $submittedObject */
$submittedObject = $form->getData();
$this->admin->setSubject($submittedObject);
try {
$existingObject = $this->admin->update($submittedObject);
if ($this->isXmlHttpRequest($request)) {
return $this->handleXmlHttpRequestSuccessResponse($request, $existingObject);
}
$this->addFlash(
'sonata_flash_success',
$this->trans(
'flash_edit_success',
['%name%' => $this->escapeHtml($this->admin->toString($existingObject))],
'SonataAdminBundle'
)
);
// redirect to edit mode
return $this->redirectTo($request, $existingObject);
} catch (ModelManagerException $e) {
// NEXT_MAJOR: Remove this catch.
$this->handleModelManagerException($e);
$isFormValid = false;
} catch (ModelManagerThrowable $e) {
$errorMessage = $this->handleModelManagerThrowable($e);
$isFormValid = false;
} catch (LockException $e) {
$this->addFlash('sonata_flash_error', $this->trans('flash_lock_error', [
'%name%' => $this->escapeHtml($this->admin->toString($existingObject)),
'%link_start%' => sprintf('<a href="%s">', $this->admin->generateObjectUrl('edit', $existingObject)),
'%link_end%' => '</a>',
], 'SonataAdminBundle'));
}
}
// show an error message if the form failed validation
if (!$isFormValid) {
if ($this->isXmlHttpRequest($request) && null !== ($response = $this->handleXmlHttpRequestErrorResponse($request, $form))) {
return $response;
}
$this->addFlash(
'sonata_flash_error',
$errorMessage ?? $this->trans(
'flash_edit_error',
['%name%' => $this->escapeHtml($this->admin->toString($existingObject))],
'SonataAdminBundle'
)
);
} elseif ($this->isPreviewRequested($request)) {
// enable the preview template if the form was valid and preview was requested
$templateKey = 'preview';
$this->admin->getShow();
}
}
$formView = $form->createView();
// set the theme for the current Admin Form
$this->setFormTheme($formView, $this->admin->getFormTheme());
$template = $this->templateRegistry->getTemplate($templateKey);
return $this->renderWithExtraParams($template, [
'action' => 'edit',
'form' => $formView,
'object' => $existingObject,
'objectId' => $objectId,
]);
}
/**
* @throws NotFoundHttpException If the HTTP method is not POST
* @throws \RuntimeException If the batch action is not defined
*/
public function batchAction(Request $request): Response
{
$restMethod = $request->getMethod();
if (Request::METHOD_POST !== $restMethod) {
throw $this->createNotFoundException(sprintf(
'Invalid request method given "%s", %s expected',
$restMethod,
Request::METHOD_POST
));
}
// check the csrf token
$this->validateCsrfToken($request, 'sonata.batch');
$confirmation = $request->get('confirmation', false);
$forwardedRequest = $request->duplicate();
$encodedData = $request->get('data');
if (null === $encodedData) {
$action = $forwardedRequest->request->get('action');
/** @var InputBag|ParameterBag $bag */
$bag = $request->request;
if ($bag instanceof InputBag) {
// symfony 5.1+
$idx = $bag->all('idx');
} else {
$idx = (array) $bag->get('idx', []);
}
$allElements = $forwardedRequest->request->getBoolean('all_elements');
$forwardedRequest->request->set('idx', $idx);
$forwardedRequest->request->set('all_elements', (string) $allElements);
$data = $forwardedRequest->request->all();
$data['all_elements'] = $allElements;
unset($data['_sonata_csrf_token']);
} else {
if (!\is_string($encodedData)) {
throw new BadRequestParamHttpException('data', 'string', $encodedData);
}
try {
$data = json_decode($encodedData, true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException $exception) {
throw new BadRequestHttpException('Unable to decode batch data');
}
$action = $data['action'];
$idx = (array) ($data['idx'] ?? []);
$allElements = (bool) ($data['all_elements'] ?? false);
$forwardedRequest->request->replace(array_merge($forwardedRequest->request->all(), $data));
}
if (!\is_string($action)) {
throw new \RuntimeException('The action is not defined');
}
$camelizedAction = InflectorFactory::create()->build()->classify($action);
try {
$batchActionExecutable = $this->getBatchActionExecutable($action);
} catch (\Throwable $error) {
$finalAction = sprintf('batchAction%s', $camelizedAction);
throw new \RuntimeException(sprintf('A `%s::%s` method must be callable or create a `controller` configuration for your batch action.', $this->admin->getBaseControllerName(), $finalAction), 0, $error);
}
$batchAction = $this->admin->getBatchActions()[$action];
$isRelevantAction = sprintf('batchAction%sIsRelevant', $camelizedAction);
if (method_exists($this, $isRelevantAction)) {
// NEXT_MAJOR: Remove if above in sonata-project/admin-bundle 5.0
@trigger_error(sprintf(
'The is relevant hook via "%s()" is deprecated since sonata-project/admin-bundle 4.12'
.' and will not be call in 5.0. Move the logic to your controller.',
$isRelevantAction,
), \E_USER_DEPRECATED);
$nonRelevantMessage = $this->$isRelevantAction($idx, $allElements, $forwardedRequest);
} else {
$nonRelevantMessage = 0 !== \count($idx) || $allElements; // at least one item is selected
}
if (!$nonRelevantMessage) { // default non relevant message (if false of null)
$nonRelevantMessage = 'flash_batch_empty';
}
$datagrid = $this->admin->getDatagrid();
$datagrid->buildPager();
if (true !== $nonRelevantMessage) {
$this->addFlash(
'sonata_flash_info',
$this->trans($nonRelevantMessage, [], 'SonataAdminBundle')
);
return $this->redirectToList();
}
$askConfirmation = $batchAction['ask_confirmation'] ?? true;
if (true === $askConfirmation && 'ok' !== $confirmation) {
$actionLabel = $batchAction['label'];
$batchTranslationDomain = $batchAction['translation_domain'] ??
$this->admin->getTranslationDomain();
$formView = $datagrid->getForm()->createView();
$this->setFormTheme($formView, $this->admin->getFilterTheme());
$template = $batchAction['template'] ?? $this->templateRegistry->getTemplate('batch_confirmation');
return $this->renderWithExtraParams($template, [
'action' => 'list',
'action_label' => $actionLabel,
'batch_translation_domain' => $batchTranslationDomain,
'datagrid' => $datagrid,
'form' => $formView,
'data' => $data,
'csrf_token' => $this->getCsrfToken('sonata.batch'),
]);
}
$query = $datagrid->getQuery();
$query->setFirstResult(null);
$query->setMaxResults(null);
$this->admin->preBatchAction($action, $query, $idx, $allElements);
foreach ($this->admin->getExtensions() as $extension) {
// NEXT_MAJOR: Remove the if-statement around the call to `$extension->preBatchAction()`
// @phpstan-ignore-next-line
if (method_exists($extension, 'preBatchAction')) {
$extension->preBatchAction($this->admin, $action, $query, $idx, $allElements);
}
}
if (\count($idx) > 0) {
$this->admin->getModelManager()->addIdentifiersToQuery($this->admin->getClass(), $query, $idx);
} elseif (!$allElements) {
$this->addFlash(
'sonata_flash_info',
$this->trans('flash_batch_no_elements_processed', [], 'SonataAdminBundle')
);
return $this->redirectToList();
}
return \call_user_func($batchActionExecutable, $query, $forwardedRequest);
}
/**
* @throws AccessDeniedException If access is not granted
*/
public function createAction(Request $request): Response
{
$this->assertObjectExists($request);
$this->admin->checkAccess('create');
// the key used to lookup the template
$templateKey = 'edit';
$class = new \ReflectionClass($this->admin->hasActiveSubClass() ? $this->admin->getActiveSubClass() : $this->admin->getClass());
if ($class->isAbstract()) {
return $this->renderWithExtraParams(
'@SonataAdmin/CRUD/select_subclass.html.twig',
[
'action' => 'create',
],
);
}
$newObject = $this->admin->getNewInstance();
$preResponse = $this->preCreate($request, $newObject);
if (null !== $preResponse) {
return $preResponse;
}
$this->admin->setSubject($newObject);
$form = $this->admin->getForm();
$form->setData($newObject);
$form->handleRequest($request);
if ($form->isSubmitted()) {
$isFormValid = $form->isValid();
// persist if the form was valid and if in preview mode the preview was approved
if ($isFormValid && (!$this->isInPreviewMode($request) || $this->isPreviewApproved($request))) {
/** @phpstan-var T $submittedObject */
$submittedObject = $form->getData();
$this->admin->setSubject($submittedObject);
try {
$newObject = $this->admin->create($submittedObject);
if ($this->isXmlHttpRequest($request)) {
return $this->handleXmlHttpRequestSuccessResponse($request, $newObject);
}
$this->addFlash(
'sonata_flash_success',
$this->trans(
'flash_create_success',
['%name%' => $this->escapeHtml($this->admin->toString($newObject))],
'SonataAdminBundle'
)
);
// redirect to edit mode
return $this->redirectTo($request, $newObject);
} catch (ModelManagerException $e) {
// NEXT_MAJOR: Remove this catch.
$this->handleModelManagerException($e);
$isFormValid = false;
} catch (ModelManagerThrowable $e) {
$errorMessage = $this->handleModelManagerThrowable($e);
$isFormValid = false;
}
}
// show an error message if the form failed validation
if (!$isFormValid) {
if ($this->isXmlHttpRequest($request) && null !== ($response = $this->handleXmlHttpRequestErrorResponse($request, $form))) {
return $response;
}
$this->addFlash(
'sonata_flash_error',
$errorMessage ?? $this->trans(
'flash_create_error',
['%name%' => $this->escapeHtml($this->admin->toString($newObject))],
'SonataAdminBundle'
)
);
} elseif ($this->isPreviewRequested($request)) {
// pick the preview template if the form was valid and preview was requested
$templateKey = 'preview';
$this->admin->getShow();
}
}
$formView = $form->createView();
// set the theme for the current Admin Form
$this->setFormTheme($formView, $this->admin->getFormTheme());
$template = $this->templateRegistry->getTemplate($templateKey);
return $this->renderWithExtraParams($template, [
'action' => 'create',
'form' => $formView,
'object' => $newObject,
'objectId' => null,
]);
}
/**
* @throws NotFoundHttpException If the object does not exist
* @throws AccessDeniedException If access is not granted
*/
public function showAction(Request $request): Response
{
$object = $this->assertObjectExists($request, true);
\assert(null !== $object);
$this->checkParentChildAssociation($request, $object);
$this->admin->checkAccess('show', $object);
$preResponse = $this->preShow($request, $object);
if (null !== $preResponse) {
return $preResponse;
}
$this->admin->setSubject($object);
$fields = $this->admin->getShow();
$template = $this->templateRegistry->getTemplate('show');
return $this->renderWithExtraParams($template, [
'action' => 'show',
'object' => $object,
'elements' => $fields,
]);
}
/**
* Show history revisions for object.
*
* @throws AccessDeniedException If access is not granted
* @throws NotFoundHttpException If the object does not exist or the audit reader is not available
*/
public function historyAction(Request $request): Response
{
$object = $this->assertObjectExists($request, true);
\assert(null !== $object);
$this->admin->checkAccess('history', $object);
$objectId = $this->admin->getNormalizedIdentifier($object);
\assert(null !== $objectId);
$manager = $this->container->get('sonata.admin.audit.manager');
\assert($manager instanceof AuditManagerInterface);
if (!$manager->hasReader($this->admin->getClass())) {
throw $this->createNotFoundException(sprintf(
'unable to find the audit reader for class : %s',
$this->admin->getClass()
));
}
$reader = $manager->getReader($this->admin->getClass());
$revisions = $reader->findRevisions($this->admin->getClass(), $objectId);
$template = $this->templateRegistry->getTemplate('history');
return $this->renderWithExtraParams($template, [
'action' => 'history',
'object' => $object,
'revisions' => $revisions,
'currentRevision' => current($revisions),
]);
}
/**
* View history revision of object.
*
* @throws AccessDeniedException If access is not granted
* @throws NotFoundHttpException If the object or revision does not exist or the audit reader is not available
*/
public function historyViewRevisionAction(Request $request, string $revision): Response
{
$object = $this->assertObjectExists($request, true);
\assert(null !== $object);
$this->admin->checkAccess('historyViewRevision', $object);
$objectId = $this->admin->getNormalizedIdentifier($object);
\assert(null !== $objectId);
$manager = $this->container->get('sonata.admin.audit.manager');
\assert($manager instanceof AuditManagerInterface);
if (!$manager->hasReader($this->admin->getClass())) {
throw $this->createNotFoundException(sprintf(
'unable to find the audit reader for class : %s',
$this->admin->getClass()
));
}
$reader = $manager->getReader($this->admin->getClass());
// retrieve the revisioned object
$object = $reader->find($this->admin->getClass(), $objectId, $revision);
if (null === $object) {
throw $this->createNotFoundException(sprintf(
'unable to find the targeted object `%s` from the revision `%s` with classname : `%s`',
$objectId,
$revision,
$this->admin->getClass()
));
}
$this->admin->setSubject($object);
$template = $this->templateRegistry->getTemplate('show');
return $this->renderWithExtraParams($template, [
'action' => 'show',
'object' => $object,
'elements' => $this->admin->getShow(),
]);
}
/**
* Compare history revisions of object.
*
* @throws AccessDeniedException If access is not granted
* @throws NotFoundHttpException If the object or revision does not exist or the audit reader is not available
*/
public function historyCompareRevisionsAction(Request $request, string $baseRevision, string $compareRevision): Response
{
$this->admin->checkAccess('historyCompareRevisions');
$object = $this->assertObjectExists($request, true);
\assert(null !== $object);
$objectId = $this->admin->getNormalizedIdentifier($object);
\assert(null !== $objectId);
$manager = $this->container->get('sonata.admin.audit.manager');
\assert($manager instanceof AuditManagerInterface);
if (!$manager->hasReader($this->admin->getClass())) {
throw $this->createNotFoundException(sprintf(
'unable to find the audit reader for class : %s',
$this->admin->getClass()
));
}
$reader = $manager->getReader($this->admin->getClass());
// retrieve the base revision
$baseObject = $reader->find($this->admin->getClass(), $objectId, $baseRevision);
if (null === $baseObject) {
throw $this->createNotFoundException(sprintf(
'unable to find the targeted object `%s` from the revision `%s` with classname : `%s`',
$objectId,
$baseRevision,
$this->admin->getClass()
));
}
// retrieve the compare revision
$compareObject = $reader->find($this->admin->getClass(), $objectId, $compareRevision);
if (null === $compareObject) {
throw $this->createNotFoundException(sprintf(
'unable to find the targeted object `%s` from the revision `%s` with classname : `%s`',
$objectId,
$compareRevision,
$this->admin->getClass()
));
}
$this->admin->setSubject($baseObject);
$template = $this->templateRegistry->getTemplate('show_compare');
return $this->renderWithExtraParams($template, [
'action' => 'show',
'object' => $baseObject,
'object_compare' => $compareObject,
'elements' => $this->admin->getShow(),
]);
}
/**
* Export data to specified format.
*
* @throws AccessDeniedException If access is not granted
* @throws \RuntimeException If the export format is invalid
*/
public function exportAction(Request $request): Response
{
$this->admin->checkAccess('export');
$format = $request->get('format');
if (!\is_string($format)) {
throw new BadRequestParamHttpException('format', 'string', $format);
}
$adminExporter = $this->container->get('sonata.admin.admin_exporter');
\assert($adminExporter instanceof AdminExporter);
$allowedExportFormats = $adminExporter->getAvailableFormats($this->admin);
$filename = $adminExporter->getExportFilename($this->admin, $format);
$exporter = $this->container->get('sonata.exporter.exporter');
\assert($exporter instanceof Exporter);
if (!\in_array($format, $allowedExportFormats, true)) {
throw new \RuntimeException(sprintf(
'Export in format `%s` is not allowed for class: `%s`. Allowed formats are: `%s`',
$format,
$this->admin->getClass(),
implode(', ', $allowedExportFormats)
));
}
return $exporter->getResponse(
$format,
$filename,
$this->admin->getDataSourceIterator()
);
}
/**
* Returns the Response object associated to the acl action.
*
* @throws AccessDeniedException If access is not granted
* @throws NotFoundHttpException If the object does not exist or the ACL is not enabled
*/
public function aclAction(Request $request): Response
{
if (!$this->admin->isAclEnabled()) {
throw $this->createNotFoundException('ACL are not enabled for this admin');
}
$object = $this->assertObjectExists($request, true);
\assert(null !== $object);
$this->admin->checkAccess('acl', $object);
$this->admin->setSubject($object);
$aclUsers = $this->getAclUsers();
$aclRoles = $this->getAclRoles();
$adminObjectAclManipulator = $this->container->get('sonata.admin.object.manipulator.acl.admin');
\assert($adminObjectAclManipulator instanceof AdminObjectAclManipulator);
$adminObjectAclData = new AdminObjectAclData(
$this->admin,
$object,
$aclUsers,
$adminObjectAclManipulator->getMaskBuilderClass(),
$aclRoles
);
$aclUsersForm = $adminObjectAclManipulator->createAclUsersForm($adminObjectAclData);
$aclRolesForm = $adminObjectAclManipulator->createAclRolesForm($adminObjectAclData);
if (Request::METHOD_POST === $request->getMethod()) {
if ($request->request->has(AdminObjectAclManipulator::ACL_USERS_FORM_NAME)) {
$form = $aclUsersForm;
$updateMethod = 'updateAclUsers';
} elseif ($request->request->has(AdminObjectAclManipulator::ACL_ROLES_FORM_NAME)) {
$form = $aclRolesForm;
$updateMethod = 'updateAclRoles';
}
if (isset($form, $updateMethod)) {
$form->handleRequest($request);
if ($form->isValid()) {
$adminObjectAclManipulator->$updateMethod($adminObjectAclData);
$this->addFlash(
'sonata_flash_success',
$this->trans('flash_acl_edit_success', [], 'SonataAdminBundle')
);
return new RedirectResponse($this->admin->generateObjectUrl('acl', $object));
}
}
}
$template = $this->templateRegistry->getTemplate('acl');
return $this->renderWithExtraParams($template, [
'action' => 'acl',
'permissions' => $adminObjectAclData->getUserPermissions(),
'object' => $object,
'users' => $aclUsers,
'roles' => $aclRoles,
'aclUsersForm' => $aclUsersForm->createView(),
'aclRolesForm' => $aclRolesForm->createView(),
]);
}
/**
* Contextualize the admin class depends on the current request.
*
* @throws \InvalidArgumentException
*/
final public function configureAdmin(Request $request): void
{
$adminFetcher = $this->container->get('sonata.admin.request.fetcher');
\assert($adminFetcher instanceof AdminFetcherInterface);
/** @var AdminInterface<T> $admin */
$admin = $adminFetcher->get($request);
$this->admin = $admin;
if (!$this->admin->hasTemplateRegistry()) {
throw new \RuntimeException(sprintf(
'Unable to find the template registry related to the current admin (%s).',
$this->admin->getCode()
));
}
$this->templateRegistry = $this->admin->getTemplateRegistry();
}
/**
* Renders a view while passing mandatory parameters on to the template.
*
* @param string $view The view name
* @param array<string, mixed> $parameters An array of parameters to pass to the view
*/
final protected function renderWithExtraParams(string $view, array $parameters = [], ?Response $response = null): Response
{
return $this->render($view, $this->addRenderExtraParams($parameters), $response);
}
/**
* @param array<string, mixed> $parameters
*
* @return array<string, mixed>
*/
protected function addRenderExtraParams(array $parameters = []): array
{
$parameters['admin'] ??= $this->admin;
$parameters['base_template'] ??= $this->getBaseTemplate();
return $parameters;
}
/**
* @param mixed $data
* @param mixed[] $headers
*/
final protected function renderJson($data, int $status = Response::HTTP_OK, array $headers = []): JsonResponse
{
return new JsonResponse($data, $status, $headers);
}
/**
* Returns true if the request is a XMLHttpRequest.
*
* @return bool True if the request is an XMLHttpRequest, false otherwise
*/
final protected function isXmlHttpRequest(Request $request): bool
{
return $request->isXmlHttpRequest()
|| $request->request->getBoolean('_xml_http_request')
|| $request->query->getBoolean('_xml_http_request');
}
/**
* Proxy for the logger service of the container.
* If no such service is found, a NullLogger is returned.
*/
protected function getLogger(): LoggerInterface
{
if ($this->container->has('logger')) {
$logger = $this->container->get('logger');
\assert($logger instanceof LoggerInterface);
return $logger;
}
return new NullLogger();
}
/**
* Returns the base template name.
*
* @return string The template name
*/
protected function getBaseTemplate(): string
{
$requestStack = $this->container->get('request_stack');
\assert($requestStack instanceof RequestStack);
$request = $requestStack->getCurrentRequest();
\assert(null !== $request);
if ($this->isXmlHttpRequest($request)) {
return $this->templateRegistry->getTemplate('ajax');
}
return $this->templateRegistry->getTemplate('layout');
}
/**
* @throws \Exception
*/
protected function handleModelManagerException(\Exception $exception): void
{
if ($exception instanceof ModelManagerThrowable) {
$this->handleModelManagerThrowable($exception);
return;
}
@trigger_error(sprintf(
'The method "%s()" is deprecated since sonata-project/admin-bundle 3.107 and will be removed in 5.0.',
__METHOD__
), \E_USER_DEPRECATED);
$debug = $this->getParameter('kernel.debug');
\assert(\is_bool($debug));
if ($debug) {
throw $exception;
}
$context = ['exception' => $exception];
if (null !== $exception->getPrevious()) {
$context['previous_exception_message'] = $exception->getPrevious()->getMessage();
}
$this->getLogger()->error($exception->getMessage(), $context);
}
/**
* NEXT_MAJOR: Add typehint.
*
* @throws ModelManagerThrowable
*
* @return string|null A custom error message to display in the flag bag instead of the generic one
*/
protected function handleModelManagerThrowable(ModelManagerThrowable $exception)
{
$debug = $this->getParameter('kernel.debug');
\assert(\is_bool($debug));
if ($debug) {
throw $exception;
}
$context = ['exception' => $exception];
if (null !== $exception->getPrevious()) {
$context['previous_exception_message'] = $exception->getPrevious()->getMessage();
}
$this->getLogger()->error($exception->getMessage(), $context);
return null;
}
/**
* Redirect the user depend on this choice.
*
* @phpstan-param T $object
*/
protected function redirectTo(Request $request, object $object): RedirectResponse
{
if (null !== $request->get('btn_update_and_list')) {
return $this->redirectToList();
}
if (null !== $request->get('btn_create_and_list')) {
return $this->redirectToList();
}
if (null !== $request->get('btn_create_and_create')) {
$params = [];
if ($this->admin->hasActiveSubClass()) {
$params['subclass'] = $request->get('subclass');
}
return new RedirectResponse($this->admin->generateUrl('create', $params));
}
if (null !== $request->get('btn_delete')) {
return $this->redirectToList();
}
foreach (['edit', 'show'] as $route) {
if ($this->admin->hasRoute($route) && $this->admin->hasAccess($route, $object)) {
$url = $this->admin->generateObjectUrl(
$route,
$object,
$this->getSelectedTab($request)
);
return new RedirectResponse($url);
}
}
return $this->redirectToList();
}
/**
* Redirects the user to the list view.
*/
final protected function redirectToList(): RedirectResponse
{
$parameters = [];
$filter = $this->admin->getFilterParameters();
if ([] !== $filter) {
$parameters['filter'] = $filter;
}
return $this->redirect($this->admin->generateUrl('list', $parameters));
}
/**
* Returns true if the preview is requested to be shown.
*/
final protected function isPreviewRequested(Request $request): bool
{
return null !== $request->get('btn_preview');
}
/**
* Returns true if the preview has been approved.
*/
final protected function isPreviewApproved(Request $request): bool
{
return null !== $request->get('btn_preview_approve');
}
/**
* Returns true if the request is in the preview workflow.
*
* That means either a preview is requested or the preview has already been shown
* and it got approved/declined.
*/
final protected function isInPreviewMode(Request $request): bool
{
return $this->admin->supportsPreviewMode()
&& ($this->isPreviewRequested($request)
|| $this->isPreviewApproved($request)
|| $this->isPreviewDeclined($request));
}
/**
* Returns true if the preview has been declined.
*/
final protected function isPreviewDeclined(Request $request): bool
{
return null !== $request->get('btn_preview_decline');
}
/**
* @return \Traversable<UserInterface|string>
*/
protected function getAclUsers(): \Traversable
{
if (!$this->container->has('sonata.admin.security.acl_user_manager')) {
return new \ArrayIterator([]);
}
$aclUserManager = $this->container->get('sonata.admin.security.acl_user_manager');
\assert($aclUserManager instanceof AdminAclUserManagerInterface);
$aclUsers = $aclUserManager->findUsers();
return \is_array($aclUsers) ? new \ArrayIterator($aclUsers) : $aclUsers;
}
/**
* @return \Traversable<string>
*/
protected function getAclRoles(): \Traversable
{
$aclRoles = [];
$roleHierarchy = $this->getParameter('security.role_hierarchy.roles');
\assert(\is_array($roleHierarchy));
$pool = $this->container->get('sonata.admin.pool');
\assert($pool instanceof Pool);
foreach ($pool->getAdminServiceCodes() as $code) {
try {
$admin = $pool->getInstance($code);
} catch (\Exception $e) {
continue;
}
$baseRole = $admin->getSecurityHandler()->getBaseRole($admin);
foreach ($admin->getSecurityInformation() as $role => $_permissions) {
$role = sprintf($baseRole, $role);
$aclRoles[] = $role;
}
}
foreach ($roleHierarchy as $name => $roles) {
$aclRoles[] = $name;
$aclRoles = array_merge($aclRoles, $roles);
}
$aclRoles = array_unique($aclRoles);
return new \ArrayIterator($aclRoles);
}
/**
* Validate CSRF token for action without form.
*
* @throws HttpException
*/
final protected function validateCsrfToken(Request $request, string $intention): void
{
if (!$this->container->has('security.csrf.token_manager')) {
return;
}
$token = $request->get('_sonata_csrf_token');
$tokenManager = $this->container->get('security.csrf.token_manager');
\assert($tokenManager instanceof CsrfTokenManagerInterface);
if (!$tokenManager->isTokenValid(new CsrfToken($intention, $token))) {
throw new HttpException(Response::HTTP_BAD_REQUEST, 'The csrf token is not valid, CSRF attack?');
}
}
/**
* Escape string for html output.
*/
final protected function escapeHtml(string $s): string
{
return htmlspecialchars($s, \ENT_QUOTES | \ENT_SUBSTITUTE);
}
/**
* Get CSRF token.
*/
final protected function getCsrfToken(string $intention): ?string
{
if (!$this->container->has('security.csrf.token_manager')) {
return null;
}
$tokenManager = $this->container->get('security.csrf.token_manager');
\assert($tokenManager instanceof CsrfTokenManagerInterface);
return $tokenManager->getToken($intention)->getValue();
}
/**
* This method can be overloaded in your custom CRUD controller.
* It's called from createAction.
*
* @phpstan-param T $object
*/
protected function preCreate(Request $request, object $object): ?Response
{
return null;
}
/**
* This method can be overloaded in your custom CRUD controller.
* It's called from editAction.
*
* @phpstan-param T $object
*/
protected function preEdit(Request $request, object $object): ?Response
{
return null;
}
/**
* This method can be overloaded in your custom CRUD controller.
* It's called from deleteAction.
*
* @phpstan-param T $object
*/
protected function preDelete(Request $request, object $object): ?Response
{
return null;
}
/**
* This method can be overloaded in your custom CRUD controller.
* It's called from showAction.
*
* @phpstan-param T $object
*/
protected function preShow(Request $request, object $object): ?Response
{
return null;
}
/**
* This method can be overloaded in your custom CRUD controller.
* It's called from listAction.
*/
protected function preList(Request $request): ?Response
{
return null;
}
/**
* Translate a message id.
*
* @param mixed[] $parameters
*/
final protected function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string
{
$domain ??= $this->admin->getTranslationDomain();
$translator = $this->container->get('translator');
\assert($translator instanceof TranslatorInterface);
return $translator->trans($id, $parameters, $domain, $locale);
}
protected function handleXmlHttpRequestErrorResponse(Request $request, FormInterface $form): ?JsonResponse
{
if ([] === array_intersect(['application/json', '*/*'], $request->getAcceptableContentTypes())) {
return $this->renderJson([], Response::HTTP_NOT_ACCEPTABLE);
}
return $this->json(
FormErrorIteratorToConstraintViolationList::transform($form->getErrors(true)),
Response::HTTP_BAD_REQUEST
);
}
/**
* @phpstan-param T $object
*/
protected function handleXmlHttpRequestSuccessResponse(Request $request, object $object): JsonResponse
{
if ([] === array_intersect(['application/json', '*/*'], $request->getAcceptableContentTypes())) {
return $this->renderJson([], Response::HTTP_NOT_ACCEPTABLE);
}
return $this->renderJson([
'result' => 'ok',
'objectId' => $this->admin->getNormalizedIdentifier($object),
'objectName' => $this->escapeHtml($this->admin->toString($object)),
]);
}
/**
* @phpstan-return T|null
*/
final protected function assertObjectExists(Request $request, bool $strict = false): ?object
{
$admin = $this->admin;
$object = null;
while (null !== $admin) {
$objectId = $request->get($admin->getIdParameter());
if (\is_string($objectId) || \is_int($objectId)) {
$adminObject = $admin->getObject($objectId);
if (null === $adminObject) {
throw $this->createNotFoundException(sprintf(
'Unable to find %s object with id: %s.',
$admin->getClassnameLabel(),
$objectId
));
} elseif (null === $object) {
/** @phpstan-var T $object */
$object = $adminObject;
}
} elseif ($strict || $admin !== $this->admin) {
throw $this->createNotFoundException(sprintf(
'Unable to find the %s object id of the admin "%s".',
$admin->getClassnameLabel(),
\get_class($admin)
));
}
$admin = $admin->isChild() ? $admin->getParent() : null;
}
return $object;
}
/**
* @return array{_tab?: string}
*/
final protected function getSelectedTab(Request $request): array
{
return array_filter(['_tab' => (string) $request->request->get('_tab')]);
}
/**
* Sets the admin form theme to form view. Used for compatibility between Symfony versions.
*
* @param string[]|null $theme
*/
final protected function setFormTheme(FormView $formView, ?array $theme = null): void
{
$twig = $this->container->get('twig');
\assert($twig instanceof Environment);
$formRenderer = $twig->getRuntime(FormRenderer::class);
$formRenderer->setTheme($formView, $theme);
}
/**
* @phpstan-param T $object
*/
final protected function checkParentChildAssociation(Request $request, object $object): void
{
if (!$this->admin->isChild()) {
return;
}
$parentAdmin = $this->admin->getParent();
$parentId = $request->get($parentAdmin->getIdParameter());
\assert(\is_string($parentId) || \is_int($parentId));
$parentAdminObject = $parentAdmin->getObject($parentId);
if (null === $parentAdminObject) {
throw new \RuntimeException(sprintf(
'No object was found in the admin "%s" for the id "%s".',
\get_class($parentAdmin),
$parentId
));
}
$parentAssociationMapping = $this->admin->getParentAssociationMapping();
if (null === $parentAssociationMapping) {
throw new \RuntimeException('The admin has no parent association mapping.');
}
$propertyAccessor = PropertyAccess::createPropertyAccessor();
$propertyPath = new PropertyPath($parentAssociationMapping);
$objectParent = $propertyAccessor->getValue($object, $propertyPath);
// $objectParent may be an array or a Collection when the parent association is many to many.
$parentObjectMatches = $this->equalsOrContains($objectParent, $parentAdminObject);
if (!$parentObjectMatches) {
throw new \RuntimeException(sprintf(
'There is no association between "%s" and "%s"',
$parentAdmin->toString($parentAdminObject),
$this->admin->toString($object)
));
}
}
private function getBatchActionExecutable(string $action): callable
{
$batchActions = $this->admin->getBatchActions();
if (!\array_key_exists($action, $batchActions)) {
throw new \RuntimeException(sprintf('The `%s` batch action is not defined', $action));
}
$controller = $batchActions[$action]['controller'] ?? sprintf(
'%s::%s',
$this->admin->getBaseControllerName(),
sprintf('batchAction%s', InflectorFactory::create()->build()->classify($action))
);
// This will throw an exception when called so we know if it's possible or not to call the controller.
$exists = false !== $this->container
->get('controller_resolver')
->getController(new Request([], [], ['_controller' => $controller]));
if (!$exists) {
throw new \RuntimeException(sprintf('Controller for action `%s` cannot be resolved', $action));
}
return function (ProxyQueryInterface $query, Request $request) use ($controller) {
$request->attributes->set('_controller', $controller);
$request->attributes->set('query', $query);
return $this->container->get('http_kernel')->handle($request, HttpKernelInterface::SUB_REQUEST);
};
}
/**
* Checks whether $needle is equal to $haystack or part of it.
*
* @param object|iterable<object> $haystack
*
* @return bool true when $haystack equals $needle or $haystack is iterable and contains $needle
*/
private function equalsOrContains($haystack, object $needle): bool
{
if ($needle === $haystack) {
return true;
}
if (is_iterable($haystack)) {
foreach ($haystack as $haystackItem) {
if ($haystackItem === $needle) {
return true;
}
}
}
return false;
}
}