vendor/sonata-project/admin-bundle/src/Controller/CRUDController.php line 108

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. /*
  4. * This file is part of the Sonata Project package.
  5. *
  6. * (c) Thomas Rabaix <thomas.rabaix@sonata-project.org>
  7. *
  8. * For the full copyright and license information, please view the LICENSE
  9. * file that was distributed with this source code.
  10. */
  11. namespace Sonata\AdminBundle\Controller;
  12. use Psr\Log\LoggerInterface;
  13. use Psr\Log\NullLogger;
  14. use Sonata\AdminBundle\Admin\AdminInterface;
  15. use Sonata\AdminBundle\Admin\Pool;
  16. use Sonata\AdminBundle\Bridge\Exporter\AdminExporter;
  17. use Sonata\AdminBundle\Datagrid\ProxyQueryInterface;
  18. use Sonata\AdminBundle\Exception\BadRequestParamHttpException;
  19. use Sonata\AdminBundle\Exception\LockException;
  20. use Sonata\AdminBundle\Exception\ModelManagerException;
  21. use Sonata\AdminBundle\Exception\ModelManagerThrowable;
  22. use Sonata\AdminBundle\Form\FormErrorIteratorToConstraintViolationList;
  23. use Sonata\AdminBundle\Model\AuditManagerInterface;
  24. use Sonata\AdminBundle\Request\AdminFetcherInterface;
  25. use Sonata\AdminBundle\Templating\TemplateRegistryInterface;
  26. use Sonata\AdminBundle\Util\AdminAclUserManagerInterface;
  27. use Sonata\AdminBundle\Util\AdminObjectAclData;
  28. use Sonata\AdminBundle\Util\AdminObjectAclManipulator;
  29. use Sonata\Exporter\ExporterInterface;
  30. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  31. use Symfony\Component\Form\FormInterface;
  32. use Symfony\Component\Form\FormRenderer;
  33. use Symfony\Component\Form\FormView;
  34. use Symfony\Component\HttpFoundation\JsonResponse;
  35. use Symfony\Component\HttpFoundation\RedirectResponse;
  36. use Symfony\Component\HttpFoundation\Request;
  37. use Symfony\Component\HttpFoundation\RequestStack;
  38. use Symfony\Component\HttpFoundation\Response;
  39. use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
  40. use Symfony\Component\HttpKernel\Exception\HttpException;
  41. use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
  42. use Symfony\Component\HttpKernel\HttpKernelInterface;
  43. use Symfony\Component\PropertyAccess\PropertyAccess;
  44. use Symfony\Component\PropertyAccess\PropertyPath;
  45. use Symfony\Component\Security\Core\Exception\AccessDeniedException;
  46. use Symfony\Component\Security\Core\User\UserInterface;
  47. use Symfony\Component\Security\Csrf\CsrfToken;
  48. use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
  49. use Symfony\Component\String\UnicodeString;
  50. use Symfony\Contracts\Translation\TranslatorInterface;
  51. use Twig\Environment;
  52. /**
  53. * @author Thomas Rabaix <thomas.rabaix@sonata-project.org>
  54. *
  55. * @phpstan-template T of object
  56. *
  57. * @psalm-suppress MissingConstructor
  58. *
  59. * @see ConfigureCRUDControllerListener
  60. */
  61. class CRUDController extends AbstractController
  62. {
  63. /**
  64. * The related Admin class.
  65. *
  66. * @var AdminInterface<object>
  67. *
  68. * @phpstan-var AdminInterface<T>
  69. *
  70. * @psalm-suppress PropertyNotSetInConstructor
  71. */
  72. protected $admin;
  73. /**
  74. * The template registry of the related Admin class.
  75. *
  76. * @psalm-suppress PropertyNotSetInConstructor
  77. * @phpstan-ignore-next-line
  78. */
  79. private TemplateRegistryInterface $templateRegistry;
  80. public static function getSubscribedServices(): array
  81. {
  82. return [
  83. 'sonata.admin.pool' => Pool::class,
  84. 'sonata.admin.audit.manager' => AuditManagerInterface::class,
  85. 'sonata.admin.object.manipulator.acl.admin' => AdminObjectAclManipulator::class,
  86. 'sonata.admin.request.fetcher' => AdminFetcherInterface::class,
  87. 'sonata.exporter.exporter' => '?'.ExporterInterface::class,
  88. 'sonata.admin.admin_exporter' => '?'.AdminExporter::class,
  89. 'sonata.admin.security.acl_user_manager' => '?'.AdminAclUserManagerInterface::class,
  90. 'controller_resolver' => 'controller_resolver',
  91. 'http_kernel' => HttpKernelInterface::class,
  92. 'logger' => '?'.LoggerInterface::class,
  93. 'translator' => TranslatorInterface::class,
  94. ] + parent::getSubscribedServices();
  95. }
  96. /**
  97. * @throws AccessDeniedException If access is not granted
  98. */
  99. public function listAction(Request $request): Response
  100. {
  101. $this->assertObjectExists($request);
  102. $this->admin->checkAccess('list');
  103. $preResponse = $this->preList($request);
  104. if (null !== $preResponse) {
  105. return $preResponse;
  106. }
  107. $listMode = $request->get('_list_mode');
  108. if (\is_string($listMode)) {
  109. $this->admin->setListMode($listMode);
  110. }
  111. $datagrid = $this->admin->getDatagrid();
  112. $formView = $datagrid->getForm()->createView();
  113. // set the theme for the current Admin Form
  114. $this->setFormTheme($formView, $this->admin->getFilterTheme());
  115. $template = $this->templateRegistry->getTemplate('list');
  116. if ($this->container->has('sonata.admin.admin_exporter')) {
  117. $exporter = $this->container->get('sonata.admin.admin_exporter');
  118. \assert($exporter instanceof AdminExporter);
  119. $exportFormats = $exporter->getAvailableFormats($this->admin);
  120. }
  121. /**
  122. * @psalm-suppress DeprecatedMethod
  123. */
  124. return $this->renderWithExtraParams($template, [
  125. 'action' => 'list',
  126. 'form' => $formView,
  127. 'datagrid' => $datagrid,
  128. 'csrf_token' => $this->getCsrfToken('sonata.batch'),
  129. 'export_formats' => $exportFormats ?? $this->admin->getExportFormats(),
  130. ]);
  131. }
  132. /**
  133. * NEXT_MAJOR: Change signature to `(ProxyQueryInterface $query, Request $request).
  134. *
  135. * Execute a batch delete.
  136. *
  137. * @throws AccessDeniedException If access is not granted
  138. *
  139. * @phpstan-param ProxyQueryInterface<T> $query
  140. */
  141. public function batchActionDelete(ProxyQueryInterface $query): Response
  142. {
  143. $this->admin->checkAccess('batchDelete');
  144. $modelManager = $this->admin->getModelManager();
  145. try {
  146. $modelManager->batchDelete($this->admin->getClass(), $query);
  147. $this->addFlash(
  148. 'sonata_flash_success',
  149. $this->trans('flash_batch_delete_success', [], 'SonataAdminBundle')
  150. );
  151. } catch (ModelManagerException $e) {
  152. // NEXT_MAJOR: Remove this catch.
  153. $errorMessage = $this->handleModelManagerException($e);
  154. $this->addFlash(
  155. 'sonata_flash_error',
  156. $errorMessage ?? $this->trans('flash_batch_delete_error', [], 'SonataAdminBundle')
  157. );
  158. } catch (ModelManagerThrowable $e) {
  159. $errorMessage = $this->handleModelManagerThrowable($e);
  160. $this->addFlash(
  161. 'sonata_flash_error',
  162. $errorMessage ?? $this->trans('flash_batch_delete_error', [], 'SonataAdminBundle')
  163. );
  164. }
  165. return $this->redirectToList();
  166. }
  167. /**
  168. * @throws NotFoundHttpException If the object does not exist
  169. * @throws AccessDeniedException If access is not granted
  170. */
  171. public function deleteAction(Request $request): Response
  172. {
  173. $object = $this->assertObjectExists($request, true);
  174. \assert(null !== $object);
  175. $this->checkParentChildAssociation($request, $object);
  176. $this->admin->checkAccess('delete', $object);
  177. $preResponse = $this->preDelete($request, $object);
  178. if (null !== $preResponse) {
  179. return $preResponse;
  180. }
  181. if (\in_array($request->getMethod(), [Request::METHOD_POST, Request::METHOD_DELETE], true)) {
  182. // check the csrf token
  183. $this->validateCsrfToken($request, 'sonata.delete');
  184. $objectName = $this->admin->toString($object);
  185. try {
  186. $this->admin->delete($object);
  187. if ($this->isXmlHttpRequest($request)) {
  188. return $this->renderJson(['result' => 'ok']);
  189. }
  190. $this->addFlash(
  191. 'sonata_flash_success',
  192. $this->trans(
  193. 'flash_delete_success',
  194. ['%name%' => $this->escapeHtml($objectName)],
  195. 'SonataAdminBundle'
  196. )
  197. );
  198. } catch (ModelManagerException $e) {
  199. // NEXT_MAJOR: Remove this catch.
  200. $errorMessage = $this->handleModelManagerException($e);
  201. if ($this->isXmlHttpRequest($request)) {
  202. return $this->renderJson(['result' => 'error']);
  203. }
  204. $this->addFlash(
  205. 'sonata_flash_error',
  206. $errorMessage ?? $this->trans(
  207. 'flash_delete_error',
  208. ['%name%' => $this->escapeHtml($objectName)],
  209. 'SonataAdminBundle'
  210. )
  211. );
  212. } catch (ModelManagerThrowable $e) {
  213. $errorMessage = $this->handleModelManagerThrowable($e);
  214. if ($this->isXmlHttpRequest($request)) {
  215. return $this->renderJson(['result' => 'error'], Response::HTTP_OK, []);
  216. }
  217. $this->addFlash(
  218. 'sonata_flash_error',
  219. $errorMessage ?? $this->trans(
  220. 'flash_delete_error',
  221. ['%name%' => $this->escapeHtml($objectName)],
  222. 'SonataAdminBundle'
  223. )
  224. );
  225. }
  226. return $this->redirectTo($request, $object);
  227. }
  228. $template = $this->templateRegistry->getTemplate('delete');
  229. /**
  230. * @psalm-suppress DeprecatedMethod
  231. */
  232. return $this->renderWithExtraParams($template, [
  233. 'object' => $object,
  234. 'action' => 'delete',
  235. 'csrf_token' => $this->getCsrfToken('sonata.delete'),
  236. ]);
  237. }
  238. /**
  239. * @throws NotFoundHttpException If the object does not exist
  240. * @throws AccessDeniedException If access is not granted
  241. */
  242. public function editAction(Request $request): Response
  243. {
  244. // the key used to lookup the template
  245. $templateKey = 'edit';
  246. $existingObject = $this->assertObjectExists($request, true);
  247. \assert(null !== $existingObject);
  248. $this->checkParentChildAssociation($request, $existingObject);
  249. $this->admin->checkAccess('edit', $existingObject);
  250. $preResponse = $this->preEdit($request, $existingObject);
  251. if (null !== $preResponse) {
  252. return $preResponse;
  253. }
  254. $this->admin->setSubject($existingObject);
  255. $objectId = $this->admin->getNormalizedIdentifier($existingObject);
  256. \assert(null !== $objectId);
  257. $form = $this->admin->getForm();
  258. $form->setData($existingObject);
  259. $form->handleRequest($request);
  260. if ($form->isSubmitted()) {
  261. $isFormValid = $form->isValid();
  262. // persist if the form was valid and if in preview mode the preview was approved
  263. if ($isFormValid && (!$this->isInPreviewMode($request) || $this->isPreviewApproved($request))) {
  264. /** @phpstan-var T $submittedObject */
  265. $submittedObject = $form->getData();
  266. $this->admin->setSubject($submittedObject);
  267. try {
  268. $existingObject = $this->admin->update($submittedObject);
  269. if ($this->isXmlHttpRequest($request)) {
  270. return $this->handleXmlHttpRequestSuccessResponse($request, $existingObject);
  271. }
  272. $this->addFlash(
  273. 'sonata_flash_success',
  274. $this->trans(
  275. 'flash_edit_success',
  276. ['%name%' => $this->escapeHtml($this->admin->toString($existingObject))],
  277. 'SonataAdminBundle'
  278. )
  279. );
  280. // redirect to edit mode
  281. return $this->redirectTo($request, $existingObject);
  282. } catch (ModelManagerException $e) {
  283. // NEXT_MAJOR: Remove this catch.
  284. $errorMessage = $this->handleModelManagerException($e);
  285. $isFormValid = false;
  286. } catch (ModelManagerThrowable $e) {
  287. $errorMessage = $this->handleModelManagerThrowable($e);
  288. $isFormValid = false;
  289. } catch (LockException) {
  290. $this->addFlash('sonata_flash_error', $this->trans('flash_lock_error', [
  291. '%name%' => $this->escapeHtml($this->admin->toString($existingObject)),
  292. '%link_start%' => \sprintf('<a href="%s">', $this->admin->generateObjectUrl('edit', $existingObject)),
  293. '%link_end%' => '</a>',
  294. ], 'SonataAdminBundle'));
  295. }
  296. }
  297. // show an error message if the form failed validation
  298. if (!$isFormValid) {
  299. if ($this->isXmlHttpRequest($request) && null !== ($response = $this->handleXmlHttpRequestErrorResponse($request, $form))) {
  300. return $response;
  301. }
  302. $this->addFlash(
  303. 'sonata_flash_error',
  304. $errorMessage ?? $this->trans(
  305. 'flash_edit_error',
  306. ['%name%' => $this->escapeHtml($this->admin->toString($existingObject))],
  307. 'SonataAdminBundle'
  308. )
  309. );
  310. } elseif ($this->isPreviewRequested($request)) {
  311. // enable the preview template if the form was valid and preview was requested
  312. $templateKey = 'preview';
  313. $this->admin->getShow();
  314. }
  315. }
  316. $formView = $form->createView();
  317. // set the theme for the current Admin Form
  318. $this->setFormTheme($formView, $this->admin->getFormTheme());
  319. $template = $this->templateRegistry->getTemplate($templateKey);
  320. /**
  321. * @psalm-suppress DeprecatedMethod
  322. */
  323. return $this->renderWithExtraParams($template, [
  324. 'action' => 'edit',
  325. 'form' => $formView,
  326. 'object' => $existingObject,
  327. 'objectId' => $objectId,
  328. ]);
  329. }
  330. /**
  331. * @throws NotFoundHttpException If the HTTP method is not POST
  332. * @throws \RuntimeException If the batch action is not defined
  333. */
  334. public function batchAction(Request $request): Response
  335. {
  336. $restMethod = $request->getMethod();
  337. if (Request::METHOD_POST !== $restMethod) {
  338. throw $this->createNotFoundException(\sprintf(
  339. 'Invalid request method given "%s", %s expected',
  340. $restMethod,
  341. Request::METHOD_POST
  342. ));
  343. }
  344. // check the csrf token
  345. $this->validateCsrfToken($request, 'sonata.batch');
  346. $confirmation = $request->get('confirmation', false);
  347. $forwardedRequest = $request->duplicate();
  348. $encodedData = $request->get('data');
  349. if (null === $encodedData) {
  350. $action = $forwardedRequest->request->get('action');
  351. $bag = $request->request;
  352. $idx = $bag->all('idx');
  353. $allElements = $forwardedRequest->request->getBoolean('all_elements');
  354. $forwardedRequest->request->set('idx', $idx);
  355. $forwardedRequest->request->set('all_elements', (string) $allElements);
  356. $data = $forwardedRequest->request->all();
  357. $data['all_elements'] = $allElements;
  358. unset($data['_sonata_csrf_token']);
  359. } else {
  360. if (!\is_string($encodedData)) {
  361. throw new BadRequestParamHttpException('data', 'string', $encodedData);
  362. }
  363. try {
  364. $data = json_decode($encodedData, true, 512, \JSON_THROW_ON_ERROR);
  365. } catch (\JsonException) {
  366. throw new BadRequestHttpException('Unable to decode batch data');
  367. }
  368. $action = $data['action'];
  369. $idx = (array) ($data['idx'] ?? []);
  370. $allElements = (bool) ($data['all_elements'] ?? false);
  371. $forwardedRequest->request->replace(array_merge($forwardedRequest->request->all(), $data));
  372. }
  373. if (!\is_string($action)) {
  374. throw new \RuntimeException('The action is not defined');
  375. }
  376. $camelizedAction = (new UnicodeString($action))->camel()->title(true)->toString();
  377. try {
  378. $batchActionExecutable = $this->getBatchActionExecutable($action);
  379. } catch (\Throwable $error) {
  380. $finalAction = \sprintf('batchAction%s', $camelizedAction);
  381. 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);
  382. }
  383. $batchAction = $this->admin->getBatchActions()[$action];
  384. $isRelevantAction = \sprintf('batchAction%sIsRelevant', $camelizedAction);
  385. if (method_exists($this, $isRelevantAction)) {
  386. // NEXT_MAJOR: Remove if above in sonata-project/admin-bundle 5.0
  387. @trigger_error(\sprintf(
  388. 'The is relevant hook via "%s()" is deprecated since sonata-project/admin-bundle 4.12'
  389. .' and will not be call in 5.0. Move the logic to your controller.',
  390. $isRelevantAction,
  391. ), \E_USER_DEPRECATED);
  392. $nonRelevantMessage = $this->$isRelevantAction($idx, $allElements, $forwardedRequest);
  393. } else {
  394. $nonRelevantMessage = 0 !== \count($idx) || $allElements; // at least one item is selected
  395. }
  396. if (!\is_string($nonRelevantMessage) && true !== $nonRelevantMessage) { // default non relevant message
  397. $nonRelevantMessage = 'flash_batch_empty';
  398. }
  399. $datagrid = $this->admin->getDatagrid();
  400. $datagrid->buildPager();
  401. if (\is_string($nonRelevantMessage)) {
  402. $this->addFlash(
  403. 'sonata_flash_info',
  404. $this->trans($nonRelevantMessage, [], 'SonataAdminBundle')
  405. );
  406. return $this->redirectToList();
  407. }
  408. $askConfirmation = $batchAction['ask_confirmation'] ?? true;
  409. if (true === $askConfirmation && 'ok' !== $confirmation) {
  410. $actionLabel = $batchAction['label'];
  411. $batchTranslationDomain = $batchAction['translation_domain'] ??
  412. $this->admin->getTranslationDomain();
  413. $formView = $datagrid->getForm()->createView();
  414. $this->setFormTheme($formView, $this->admin->getFilterTheme());
  415. $template = $batchAction['template'] ?? $this->templateRegistry->getTemplate('batch_confirmation');
  416. /**
  417. * @psalm-suppress DeprecatedMethod
  418. */
  419. return $this->renderWithExtraParams($template, [
  420. 'action' => 'list',
  421. 'action_label' => $actionLabel,
  422. 'batch_translation_domain' => $batchTranslationDomain,
  423. 'datagrid' => $datagrid,
  424. 'form' => $formView,
  425. 'data' => $data,
  426. 'csrf_token' => $this->getCsrfToken('sonata.batch'),
  427. ]);
  428. }
  429. $query = $datagrid->getQuery();
  430. $query->setFirstResult(null);
  431. $query->setMaxResults(null);
  432. $this->admin->preBatchAction($action, $query, $idx, $allElements);
  433. foreach ($this->admin->getExtensions() as $extension) {
  434. // NEXT_MAJOR: Remove the if-statement around the call to `$extension->preBatchAction()`
  435. if (method_exists($extension, 'preBatchAction')) {
  436. $extension->preBatchAction($this->admin, $action, $query, $idx, $allElements);
  437. }
  438. }
  439. if (!$allElements) {
  440. if (\count($idx) > 0) {
  441. $this->admin->getModelManager()->addIdentifiersToQuery($this->admin->getClass(), $query, $idx);
  442. } else {
  443. $this->addFlash(
  444. 'sonata_flash_info',
  445. $this->trans('flash_batch_no_elements_processed', [], 'SonataAdminBundle')
  446. );
  447. return $this->redirectToList();
  448. }
  449. }
  450. return \call_user_func($batchActionExecutable, $query, $forwardedRequest);
  451. }
  452. /**
  453. * @throws AccessDeniedException If access is not granted
  454. */
  455. public function createAction(Request $request): Response
  456. {
  457. $this->assertObjectExists($request);
  458. $this->admin->checkAccess('create');
  459. // the key used to lookup the template
  460. $templateKey = 'edit';
  461. $class = new \ReflectionClass($this->admin->hasActiveSubClass() ? $this->admin->getActiveSubClass() : $this->admin->getClass());
  462. if ($class->isAbstract()) {
  463. /**
  464. * @psalm-suppress DeprecatedMethod
  465. */
  466. return $this->renderWithExtraParams(
  467. '@SonataAdmin/CRUD/select_subclass.html.twig',
  468. [
  469. 'action' => 'create',
  470. ],
  471. );
  472. }
  473. $newObject = $this->admin->getNewInstance();
  474. $preResponse = $this->preCreate($request, $newObject);
  475. if (null !== $preResponse) {
  476. return $preResponse;
  477. }
  478. $this->admin->setSubject($newObject);
  479. $form = $this->admin->getForm();
  480. $form->setData($newObject);
  481. $form->handleRequest($request);
  482. if ($form->isSubmitted()) {
  483. $isFormValid = $form->isValid();
  484. // persist if the form was valid and if in preview mode the preview was approved
  485. if ($isFormValid && (!$this->isInPreviewMode($request) || $this->isPreviewApproved($request))) {
  486. /** @phpstan-var T $submittedObject */
  487. $submittedObject = $form->getData();
  488. $this->admin->setSubject($submittedObject);
  489. try {
  490. $newObject = $this->admin->create($submittedObject);
  491. if ($this->isXmlHttpRequest($request)) {
  492. return $this->handleXmlHttpRequestSuccessResponse($request, $newObject);
  493. }
  494. $this->addFlash(
  495. 'sonata_flash_success',
  496. $this->trans(
  497. 'flash_create_success',
  498. ['%name%' => $this->escapeHtml($this->admin->toString($newObject))],
  499. 'SonataAdminBundle'
  500. )
  501. );
  502. // redirect to edit mode
  503. return $this->redirectTo($request, $newObject);
  504. } catch (ModelManagerException $e) {
  505. // NEXT_MAJOR: Remove this catch.
  506. $errorMessage = $this->handleModelManagerException($e);
  507. $isFormValid = false;
  508. } catch (ModelManagerThrowable $e) {
  509. $errorMessage = $this->handleModelManagerThrowable($e);
  510. $isFormValid = false;
  511. }
  512. }
  513. // show an error message if the form failed validation
  514. if (!$isFormValid) {
  515. if ($this->isXmlHttpRequest($request) && null !== ($response = $this->handleXmlHttpRequestErrorResponse($request, $form))) {
  516. return $response;
  517. }
  518. $this->addFlash(
  519. 'sonata_flash_error',
  520. $errorMessage ?? $this->trans(
  521. 'flash_create_error',
  522. ['%name%' => $this->escapeHtml($this->admin->toString($newObject))],
  523. 'SonataAdminBundle'
  524. )
  525. );
  526. } elseif ($this->isPreviewRequested($request)) {
  527. // pick the preview template if the form was valid and preview was requested
  528. $templateKey = 'preview';
  529. $this->admin->getShow();
  530. }
  531. }
  532. $formView = $form->createView();
  533. // set the theme for the current Admin Form
  534. $this->setFormTheme($formView, $this->admin->getFormTheme());
  535. $template = $this->templateRegistry->getTemplate($templateKey);
  536. /**
  537. * @psalm-suppress DeprecatedMethod
  538. */
  539. return $this->renderWithExtraParams($template, [
  540. 'action' => 'create',
  541. 'form' => $formView,
  542. 'object' => $newObject,
  543. 'objectId' => null,
  544. ]);
  545. }
  546. /**
  547. * @throws NotFoundHttpException If the object does not exist
  548. * @throws AccessDeniedException If access is not granted
  549. */
  550. public function showAction(Request $request): Response
  551. {
  552. $object = $this->assertObjectExists($request, true);
  553. \assert(null !== $object);
  554. $this->checkParentChildAssociation($request, $object);
  555. $this->admin->checkAccess('show', $object);
  556. $preResponse = $this->preShow($request, $object);
  557. if (null !== $preResponse) {
  558. return $preResponse;
  559. }
  560. $this->admin->setSubject($object);
  561. $fields = $this->admin->getShow();
  562. $template = $this->templateRegistry->getTemplate('show');
  563. /**
  564. * @psalm-suppress DeprecatedMethod
  565. */
  566. return $this->renderWithExtraParams($template, [
  567. 'action' => 'show',
  568. 'object' => $object,
  569. 'elements' => $fields,
  570. ]);
  571. }
  572. /**
  573. * Show history revisions for object.
  574. *
  575. * @throws AccessDeniedException If access is not granted
  576. * @throws NotFoundHttpException If the object does not exist or the audit reader is not available
  577. */
  578. public function historyAction(Request $request): Response
  579. {
  580. $object = $this->assertObjectExists($request, true);
  581. \assert(null !== $object);
  582. $this->admin->checkAccess('history', $object);
  583. $objectId = $this->admin->getNormalizedIdentifier($object);
  584. \assert(null !== $objectId);
  585. $manager = $this->container->get('sonata.admin.audit.manager');
  586. \assert($manager instanceof AuditManagerInterface);
  587. if (!$manager->hasReader($this->admin->getClass())) {
  588. throw $this->createNotFoundException(\sprintf(
  589. 'unable to find the audit reader for class : %s',
  590. $this->admin->getClass()
  591. ));
  592. }
  593. $reader = $manager->getReader($this->admin->getClass());
  594. $revisions = $reader->findRevisions($this->admin->getClass(), $objectId);
  595. $template = $this->templateRegistry->getTemplate('history');
  596. /**
  597. * @psalm-suppress DeprecatedMethod
  598. */
  599. return $this->renderWithExtraParams($template, [
  600. 'action' => 'history',
  601. 'object' => $object,
  602. 'revisions' => $revisions,
  603. 'currentRevision' => current($revisions),
  604. ]);
  605. }
  606. /**
  607. * View history revision of object.
  608. *
  609. * @throws AccessDeniedException If access is not granted
  610. * @throws NotFoundHttpException If the object or revision does not exist or the audit reader is not available
  611. */
  612. public function historyViewRevisionAction(Request $request, string $revision): Response
  613. {
  614. $object = $this->assertObjectExists($request, true);
  615. \assert(null !== $object);
  616. $this->admin->checkAccess('historyViewRevision', $object);
  617. $objectId = $this->admin->getNormalizedIdentifier($object);
  618. \assert(null !== $objectId);
  619. $manager = $this->container->get('sonata.admin.audit.manager');
  620. \assert($manager instanceof AuditManagerInterface);
  621. if (!$manager->hasReader($this->admin->getClass())) {
  622. throw $this->createNotFoundException(\sprintf(
  623. 'unable to find the audit reader for class : %s',
  624. $this->admin->getClass()
  625. ));
  626. }
  627. $reader = $manager->getReader($this->admin->getClass());
  628. // retrieve the revisioned object
  629. $object = $reader->find($this->admin->getClass(), $objectId, $revision);
  630. if (null === $object) {
  631. throw $this->createNotFoundException(\sprintf(
  632. 'unable to find the targeted object `%s` from the revision `%s` with classname : `%s`',
  633. $objectId,
  634. $revision,
  635. $this->admin->getClass()
  636. ));
  637. }
  638. $this->admin->setSubject($object);
  639. $template = $this->templateRegistry->getTemplate('show');
  640. /**
  641. * @psalm-suppress DeprecatedMethod
  642. */
  643. return $this->renderWithExtraParams($template, [
  644. 'action' => 'show',
  645. 'object' => $object,
  646. 'elements' => $this->admin->getShow(),
  647. ]);
  648. }
  649. /**
  650. * Compare history revisions of object.
  651. *
  652. * @throws AccessDeniedException If access is not granted
  653. * @throws NotFoundHttpException If the object or revision does not exist or the audit reader is not available
  654. */
  655. public function historyCompareRevisionsAction(Request $request, string $baseRevision, string $compareRevision): Response
  656. {
  657. $this->admin->checkAccess('historyCompareRevisions');
  658. $object = $this->assertObjectExists($request, true);
  659. \assert(null !== $object);
  660. $objectId = $this->admin->getNormalizedIdentifier($object);
  661. \assert(null !== $objectId);
  662. $manager = $this->container->get('sonata.admin.audit.manager');
  663. \assert($manager instanceof AuditManagerInterface);
  664. if (!$manager->hasReader($this->admin->getClass())) {
  665. throw $this->createNotFoundException(\sprintf(
  666. 'unable to find the audit reader for class : %s',
  667. $this->admin->getClass()
  668. ));
  669. }
  670. $reader = $manager->getReader($this->admin->getClass());
  671. // retrieve the base revision
  672. $baseObject = $reader->find($this->admin->getClass(), $objectId, $baseRevision);
  673. if (null === $baseObject) {
  674. throw $this->createNotFoundException(\sprintf(
  675. 'unable to find the targeted object `%s` from the revision `%s` with classname : `%s`',
  676. $objectId,
  677. $baseRevision,
  678. $this->admin->getClass()
  679. ));
  680. }
  681. // retrieve the compare revision
  682. $compareObject = $reader->find($this->admin->getClass(), $objectId, $compareRevision);
  683. if (null === $compareObject) {
  684. throw $this->createNotFoundException(\sprintf(
  685. 'unable to find the targeted object `%s` from the revision `%s` with classname : `%s`',
  686. $objectId,
  687. $compareRevision,
  688. $this->admin->getClass()
  689. ));
  690. }
  691. $this->admin->setSubject($baseObject);
  692. $template = $this->templateRegistry->getTemplate('show_compare');
  693. /**
  694. * @psalm-suppress DeprecatedMethod
  695. */
  696. return $this->renderWithExtraParams($template, [
  697. 'action' => 'show',
  698. 'object' => $baseObject,
  699. 'object_compare' => $compareObject,
  700. 'elements' => $this->admin->getShow(),
  701. ]);
  702. }
  703. /**
  704. * Export data to specified format.
  705. *
  706. * @throws AccessDeniedException If access is not granted
  707. * @throws \RuntimeException If the export format is invalid
  708. */
  709. public function exportAction(Request $request): Response
  710. {
  711. $this->admin->checkAccess('export');
  712. $format = $request->get('format');
  713. if (!\is_string($format)) {
  714. throw new BadRequestParamHttpException('format', 'string', $format);
  715. }
  716. $adminExporter = $this->container->get('sonata.admin.admin_exporter');
  717. \assert($adminExporter instanceof AdminExporter);
  718. $allowedExportFormats = $adminExporter->getAvailableFormats($this->admin);
  719. $filename = $adminExporter->getExportFilename($this->admin, $format);
  720. $exporter = $this->container->get('sonata.exporter.exporter');
  721. \assert($exporter instanceof ExporterInterface);
  722. if (!\in_array($format, $allowedExportFormats, true)) {
  723. throw new \RuntimeException(\sprintf(
  724. 'Export in format `%s` is not allowed for class: `%s`. Allowed formats are: `%s`',
  725. $format,
  726. $this->admin->getClass(),
  727. implode(', ', $allowedExportFormats)
  728. ));
  729. }
  730. return $exporter->getResponse(
  731. $format,
  732. $filename,
  733. $this->admin->getDataSourceIterator()
  734. );
  735. }
  736. /**
  737. * Returns the Response object associated to the acl action.
  738. *
  739. * @throws AccessDeniedException If access is not granted
  740. * @throws NotFoundHttpException If the object does not exist or the ACL is not enabled
  741. */
  742. public function aclAction(Request $request): Response
  743. {
  744. if (!$this->admin->isAclEnabled()) {
  745. throw $this->createNotFoundException('ACL are not enabled for this admin');
  746. }
  747. $object = $this->assertObjectExists($request, true);
  748. \assert(null !== $object);
  749. $this->admin->checkAccess('acl', $object);
  750. $this->admin->setSubject($object);
  751. $aclUsers = $this->getAclUsers();
  752. $aclRoles = $this->getAclRoles();
  753. $adminObjectAclManipulator = $this->container->get('sonata.admin.object.manipulator.acl.admin');
  754. \assert($adminObjectAclManipulator instanceof AdminObjectAclManipulator);
  755. $adminObjectAclData = new AdminObjectAclData(
  756. $this->admin,
  757. $object,
  758. $aclUsers,
  759. $adminObjectAclManipulator->getMaskBuilderClass(),
  760. $aclRoles
  761. );
  762. $aclUsersForm = $adminObjectAclManipulator->createAclUsersForm($adminObjectAclData);
  763. $aclRolesForm = $adminObjectAclManipulator->createAclRolesForm($adminObjectAclData);
  764. if (Request::METHOD_POST === $request->getMethod()) {
  765. if ($request->request->has(AdminObjectAclManipulator::ACL_USERS_FORM_NAME)) {
  766. $form = $aclUsersForm;
  767. $updateMethod = 'updateAclUsers';
  768. } elseif ($request->request->has(AdminObjectAclManipulator::ACL_ROLES_FORM_NAME)) {
  769. $form = $aclRolesForm;
  770. $updateMethod = 'updateAclRoles';
  771. }
  772. if (isset($form, $updateMethod)) {
  773. $form->handleRequest($request);
  774. if ($form->isValid()) {
  775. $adminObjectAclManipulator->$updateMethod($adminObjectAclData);
  776. $this->addFlash(
  777. 'sonata_flash_success',
  778. $this->trans('flash_acl_edit_success', [], 'SonataAdminBundle')
  779. );
  780. return new RedirectResponse($this->admin->generateObjectUrl('acl', $object));
  781. }
  782. }
  783. }
  784. $template = $this->templateRegistry->getTemplate('acl');
  785. /**
  786. * @psalm-suppress DeprecatedMethod
  787. */
  788. return $this->renderWithExtraParams($template, [
  789. 'action' => 'acl',
  790. 'permissions' => $adminObjectAclData->getUserPermissions(),
  791. 'object' => $object,
  792. 'users' => $aclUsers,
  793. 'roles' => $aclRoles,
  794. 'aclUsersForm' => $aclUsersForm->createView(),
  795. 'aclRolesForm' => $aclRolesForm->createView(),
  796. ]);
  797. }
  798. /**
  799. * Contextualize the admin class depends on the current request.
  800. *
  801. * @throws \InvalidArgumentException
  802. */
  803. final public function configureAdmin(Request $request): void
  804. {
  805. $adminFetcher = $this->container->get('sonata.admin.request.fetcher');
  806. \assert($adminFetcher instanceof AdminFetcherInterface);
  807. /** @var AdminInterface<T> $admin */
  808. $admin = $adminFetcher->get($request);
  809. $this->admin = $admin;
  810. if (!$this->admin->hasTemplateRegistry()) {
  811. throw new \RuntimeException(\sprintf(
  812. 'Unable to find the template registry related to the current admin (%s).',
  813. $this->admin->getCode()
  814. ));
  815. }
  816. $this->templateRegistry = $this->admin->getTemplateRegistry();
  817. }
  818. /**
  819. * Add twig globals which are used in every template.
  820. */
  821. final public function setTwigGlobals(Request $request): void
  822. {
  823. $this->setTwigGlobal('admin', $this->admin);
  824. if ($this->isXmlHttpRequest($request)) {
  825. $baseTemplate = $this->templateRegistry->getTemplate('ajax');
  826. } else {
  827. $baseTemplate = $this->templateRegistry->getTemplate('layout');
  828. }
  829. $this->setTwigGlobal('base_template', $baseTemplate);
  830. }
  831. /**
  832. * Renders a view while passing mandatory parameters on to the template.
  833. *
  834. * @param string $view The view name
  835. * @param array<string, mixed> $parameters An array of parameters to pass to the view
  836. *
  837. * @deprecated since sonata-project/admin-bundle version 4.x
  838. *
  839. * NEXT_MAJOR: Remove this method
  840. */
  841. final protected function renderWithExtraParams(string $view, array $parameters = [], ?Response $response = null): Response
  842. {
  843. /**
  844. * @psalm-suppress DeprecatedMethod
  845. */
  846. return $this->render($view, $this->addRenderExtraParams($parameters), $response);
  847. }
  848. /**
  849. * @param array<string, mixed> $parameters
  850. *
  851. * @return array<string, mixed>
  852. *
  853. * @deprecated since sonata-project/admin-bundle version 4.x
  854. *
  855. * NEXT_MAJOR: Remove this method
  856. */
  857. protected function addRenderExtraParams(array $parameters = []): array
  858. {
  859. $parameters['admin'] ??= $this->admin;
  860. /**
  861. * @psalm-suppress DeprecatedMethod
  862. */
  863. $parameters['base_template'] ??= $this->getBaseTemplate();
  864. return $parameters;
  865. }
  866. /**
  867. * @param mixed[] $headers
  868. */
  869. final protected function renderJson(mixed $data, int $status = Response::HTTP_OK, array $headers = []): JsonResponse
  870. {
  871. return new JsonResponse($data, $status, $headers);
  872. }
  873. /**
  874. * Returns true if the request is a XMLHttpRequest.
  875. *
  876. * @return bool True if the request is an XMLHttpRequest, false otherwise
  877. */
  878. final protected function isXmlHttpRequest(Request $request): bool
  879. {
  880. return $request->isXmlHttpRequest()
  881. || $request->request->getBoolean('_xml_http_request')
  882. || $request->query->getBoolean('_xml_http_request');
  883. }
  884. /**
  885. * Proxy for the logger service of the container.
  886. * If no such service is found, a NullLogger is returned.
  887. */
  888. protected function getLogger(): LoggerInterface
  889. {
  890. if ($this->container->has('logger')) {
  891. $logger = $this->container->get('logger');
  892. \assert($logger instanceof LoggerInterface);
  893. return $logger;
  894. }
  895. return new NullLogger();
  896. }
  897. /**
  898. * Returns the base template name.
  899. *
  900. * @return string The template name
  901. *
  902. * @deprecated since sonata-project/admin-bundle version 4.x
  903. *
  904. * NEXT_MAJOR: Remove this method
  905. */
  906. protected function getBaseTemplate(): string
  907. {
  908. $requestStack = $this->container->get('request_stack');
  909. \assert($requestStack instanceof RequestStack);
  910. $request = $requestStack->getCurrentRequest();
  911. \assert(null !== $request);
  912. if ($this->isXmlHttpRequest($request)) {
  913. return $this->templateRegistry->getTemplate('ajax');
  914. }
  915. return $this->templateRegistry->getTemplate('layout');
  916. }
  917. /**
  918. * @throws \Exception
  919. *
  920. * @return string|null A custom error message to display in the flag bag instead of the generic one
  921. */
  922. protected function handleModelManagerException(\Exception $exception)
  923. {
  924. if ($exception instanceof ModelManagerThrowable) {
  925. return $this->handleModelManagerThrowable($exception);
  926. }
  927. @trigger_error(\sprintf(
  928. 'The method "%s()" is deprecated since sonata-project/admin-bundle 3.107 and will be removed in 5.0.',
  929. __METHOD__
  930. ), \E_USER_DEPRECATED);
  931. $debug = $this->getParameter('kernel.debug');
  932. \assert(\is_bool($debug));
  933. if ($debug) {
  934. throw $exception;
  935. }
  936. $context = ['exception' => $exception];
  937. if (null !== $exception->getPrevious()) {
  938. $context['previous_exception_message'] = $exception->getPrevious()->getMessage();
  939. }
  940. $this->getLogger()->error($exception->getMessage(), $context);
  941. return null;
  942. }
  943. /**
  944. * NEXT_MAJOR: Add typehint.
  945. *
  946. * @throws ModelManagerThrowable
  947. *
  948. * @return string|null A custom error message to display in the flag bag instead of the generic one
  949. */
  950. protected function handleModelManagerThrowable(ModelManagerThrowable $exception)
  951. {
  952. $debug = $this->getParameter('kernel.debug');
  953. \assert(\is_bool($debug));
  954. if ($debug) {
  955. throw $exception;
  956. }
  957. $context = ['exception' => $exception];
  958. if (null !== $exception->getPrevious()) {
  959. $context['previous_exception_message'] = $exception->getPrevious()->getMessage();
  960. }
  961. $this->getLogger()->error($exception->getMessage(), $context);
  962. return null;
  963. }
  964. /**
  965. * Redirect the user depend on this choice.
  966. *
  967. * @phpstan-param T $object
  968. */
  969. protected function redirectTo(Request $request, object $object): RedirectResponse
  970. {
  971. if (null !== $request->get('btn_update_and_list')) {
  972. return $this->redirectToList();
  973. }
  974. if (null !== $request->get('btn_create_and_list')) {
  975. return $this->redirectToList();
  976. }
  977. if (null !== $request->get('btn_create_and_create')) {
  978. $params = [];
  979. if ($this->admin->hasActiveSubClass()) {
  980. $params['subclass'] = $request->get('subclass');
  981. }
  982. return new RedirectResponse($this->admin->generateUrl('create', $params));
  983. }
  984. if (null !== $request->get('btn_delete')) {
  985. return $this->redirectToList();
  986. }
  987. foreach (['edit', 'show'] as $route) {
  988. if ($this->admin->hasRoute($route) && $this->admin->hasAccess($route, $object)) {
  989. $url = $this->admin->generateObjectUrl(
  990. $route,
  991. $object,
  992. $this->getSelectedTab($request)
  993. );
  994. return new RedirectResponse($url);
  995. }
  996. }
  997. return $this->redirectToList();
  998. }
  999. /**
  1000. * Redirects the user to the list view.
  1001. */
  1002. final protected function redirectToList(): RedirectResponse
  1003. {
  1004. $parameters = [];
  1005. $filter = $this->admin->getFilterParameters();
  1006. if ([] !== $filter) {
  1007. $parameters['filter'] = $filter;
  1008. }
  1009. return $this->redirect($this->admin->generateUrl('list', $parameters));
  1010. }
  1011. /**
  1012. * Returns true if the preview is requested to be shown.
  1013. */
  1014. final protected function isPreviewRequested(Request $request): bool
  1015. {
  1016. return null !== $request->get('btn_preview');
  1017. }
  1018. /**
  1019. * Returns true if the preview has been approved.
  1020. */
  1021. final protected function isPreviewApproved(Request $request): bool
  1022. {
  1023. return null !== $request->get('btn_preview_approve');
  1024. }
  1025. /**
  1026. * Returns true if the request is in the preview workflow.
  1027. *
  1028. * That means either a preview is requested or the preview has already been shown
  1029. * and it got approved/declined.
  1030. */
  1031. final protected function isInPreviewMode(Request $request): bool
  1032. {
  1033. return $this->admin->supportsPreviewMode()
  1034. && ($this->isPreviewRequested($request)
  1035. || $this->isPreviewApproved($request)
  1036. || $this->isPreviewDeclined($request));
  1037. }
  1038. /**
  1039. * Returns true if the preview has been declined.
  1040. */
  1041. final protected function isPreviewDeclined(Request $request): bool
  1042. {
  1043. return null !== $request->get('btn_preview_decline');
  1044. }
  1045. /**
  1046. * @return \Traversable<UserInterface|string>
  1047. */
  1048. protected function getAclUsers(): \Traversable
  1049. {
  1050. if (!$this->container->has('sonata.admin.security.acl_user_manager')) {
  1051. return new \ArrayIterator([]);
  1052. }
  1053. $aclUserManager = $this->container->get('sonata.admin.security.acl_user_manager');
  1054. \assert($aclUserManager instanceof AdminAclUserManagerInterface);
  1055. $aclUsers = $aclUserManager->findUsers();
  1056. return \is_array($aclUsers) ? new \ArrayIterator($aclUsers) : $aclUsers;
  1057. }
  1058. /**
  1059. * @return \Traversable<string>
  1060. */
  1061. protected function getAclRoles(): \Traversable
  1062. {
  1063. $aclRoles = [];
  1064. $roleHierarchy = $this->getParameter('security.role_hierarchy.roles');
  1065. \assert(\is_array($roleHierarchy));
  1066. $pool = $this->container->get('sonata.admin.pool');
  1067. \assert($pool instanceof Pool);
  1068. foreach ($pool->getAdminServiceCodes() as $code) {
  1069. try {
  1070. $admin = $pool->getInstance($code);
  1071. } catch (\Exception) {
  1072. continue;
  1073. }
  1074. $baseRole = $admin->getSecurityHandler()->getBaseRole($admin);
  1075. foreach ($admin->getSecurityInformation() as $role => $_permissions) {
  1076. $role = \sprintf($baseRole, $role);
  1077. $aclRoles[] = $role;
  1078. }
  1079. }
  1080. foreach ($roleHierarchy as $name => $roles) {
  1081. $aclRoles[] = $name;
  1082. $aclRoles = array_merge($aclRoles, $roles);
  1083. }
  1084. $aclRoles = array_unique($aclRoles);
  1085. return new \ArrayIterator($aclRoles);
  1086. }
  1087. /**
  1088. * Validate CSRF token for action without form.
  1089. *
  1090. * @throws HttpException
  1091. */
  1092. final protected function validateCsrfToken(Request $request, string $intention): void
  1093. {
  1094. if (!$this->container->has('security.csrf.token_manager')) {
  1095. return;
  1096. }
  1097. $token = $request->get('_sonata_csrf_token');
  1098. $tokenManager = $this->container->get('security.csrf.token_manager');
  1099. \assert($tokenManager instanceof CsrfTokenManagerInterface);
  1100. if (!$tokenManager->isTokenValid(new CsrfToken($intention, $token))) {
  1101. throw new HttpException(Response::HTTP_BAD_REQUEST, 'The csrf token is not valid, CSRF attack?');
  1102. }
  1103. }
  1104. /**
  1105. * Escape string for html output.
  1106. */
  1107. final protected function escapeHtml(string $s): string
  1108. {
  1109. return htmlspecialchars($s, \ENT_QUOTES | \ENT_SUBSTITUTE);
  1110. }
  1111. /**
  1112. * Get CSRF token.
  1113. */
  1114. final protected function getCsrfToken(string $intention): ?string
  1115. {
  1116. if (!$this->container->has('security.csrf.token_manager')) {
  1117. return null;
  1118. }
  1119. $tokenManager = $this->container->get('security.csrf.token_manager');
  1120. \assert($tokenManager instanceof CsrfTokenManagerInterface);
  1121. return $tokenManager->getToken($intention)->getValue();
  1122. }
  1123. /**
  1124. * This method can be overloaded in your custom CRUD controller.
  1125. * It's called from createAction.
  1126. *
  1127. * @phpstan-param T $object
  1128. */
  1129. protected function preCreate(Request $request, object $object): ?Response
  1130. {
  1131. return null;
  1132. }
  1133. /**
  1134. * This method can be overloaded in your custom CRUD controller.
  1135. * It's called from editAction.
  1136. *
  1137. * @phpstan-param T $object
  1138. */
  1139. protected function preEdit(Request $request, object $object): ?Response
  1140. {
  1141. return null;
  1142. }
  1143. /**
  1144. * This method can be overloaded in your custom CRUD controller.
  1145. * It's called from deleteAction.
  1146. *
  1147. * @phpstan-param T $object
  1148. */
  1149. protected function preDelete(Request $request, object $object): ?Response
  1150. {
  1151. return null;
  1152. }
  1153. /**
  1154. * This method can be overloaded in your custom CRUD controller.
  1155. * It's called from showAction.
  1156. *
  1157. * @phpstan-param T $object
  1158. */
  1159. protected function preShow(Request $request, object $object): ?Response
  1160. {
  1161. return null;
  1162. }
  1163. /**
  1164. * This method can be overloaded in your custom CRUD controller.
  1165. * It's called from listAction.
  1166. */
  1167. protected function preList(Request $request): ?Response
  1168. {
  1169. return null;
  1170. }
  1171. /**
  1172. * Translate a message id.
  1173. *
  1174. * @param mixed[] $parameters
  1175. */
  1176. final protected function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string
  1177. {
  1178. $domain ??= $this->admin->getTranslationDomain();
  1179. $translator = $this->container->get('translator');
  1180. \assert($translator instanceof TranslatorInterface);
  1181. return $translator->trans($id, $parameters, $domain, $locale);
  1182. }
  1183. protected function handleXmlHttpRequestErrorResponse(Request $request, FormInterface $form): ?JsonResponse
  1184. {
  1185. if ([] === array_intersect(['application/json', '*/*'], $request->getAcceptableContentTypes())) {
  1186. return $this->renderJson([], Response::HTTP_NOT_ACCEPTABLE);
  1187. }
  1188. return $this->json(
  1189. FormErrorIteratorToConstraintViolationList::transform($form->getErrors(true)),
  1190. Response::HTTP_BAD_REQUEST
  1191. );
  1192. }
  1193. /**
  1194. * @phpstan-param T $object
  1195. */
  1196. protected function handleXmlHttpRequestSuccessResponse(Request $request, object $object): JsonResponse
  1197. {
  1198. if ([] === array_intersect(['application/json', '*/*'], $request->getAcceptableContentTypes())) {
  1199. return $this->renderJson([], Response::HTTP_NOT_ACCEPTABLE);
  1200. }
  1201. return $this->renderJson([
  1202. 'result' => 'ok',
  1203. 'objectId' => $this->admin->getNormalizedIdentifier($object),
  1204. 'objectName' => $this->escapeHtml($this->admin->toString($object)),
  1205. ]);
  1206. }
  1207. /**
  1208. * @phpstan-return T|null
  1209. */
  1210. final protected function assertObjectExists(Request $request, bool $strict = false): ?object
  1211. {
  1212. $admin = $this->admin;
  1213. $object = null;
  1214. while (null !== $admin) {
  1215. $objectId = $request->get($admin->getIdParameter());
  1216. if (\is_string($objectId) || \is_int($objectId)) {
  1217. $adminObject = $admin->getObject($objectId);
  1218. if (null === $adminObject) {
  1219. throw $this->createNotFoundException(\sprintf(
  1220. 'Unable to find %s object with id: %s.',
  1221. $admin->getClassnameLabel(),
  1222. $objectId
  1223. ));
  1224. }
  1225. if (null === $object) {
  1226. /** @phpstan-var T $object */
  1227. $object = $adminObject;
  1228. }
  1229. } elseif ($strict || $admin !== $this->admin) {
  1230. throw $this->createNotFoundException(\sprintf(
  1231. 'Unable to find the %s object id of the admin "%s".',
  1232. $admin->getClassnameLabel(),
  1233. $admin::class
  1234. ));
  1235. }
  1236. $admin = $admin->isChild() ? $admin->getParent() : null;
  1237. }
  1238. return $object;
  1239. }
  1240. /**
  1241. * @return array{_tab?: string}
  1242. */
  1243. final protected function getSelectedTab(Request $request): array
  1244. {
  1245. $tab = (string) $request->request->get('_tab');
  1246. if ('' === $tab) {
  1247. return [];
  1248. }
  1249. return ['_tab' => $tab];
  1250. }
  1251. /**
  1252. * Sets the admin form theme to form view. Used for compatibility between Symfony versions.
  1253. *
  1254. * @param string[]|null $theme
  1255. */
  1256. final protected function setFormTheme(FormView $formView, ?array $theme = null): void
  1257. {
  1258. $twig = $this->container->get('twig');
  1259. \assert($twig instanceof Environment);
  1260. $formRenderer = $twig->getRuntime(FormRenderer::class);
  1261. $formRenderer->setTheme($formView, $theme);
  1262. }
  1263. /**
  1264. * @phpstan-param T $object
  1265. */
  1266. final protected function checkParentChildAssociation(Request $request, object $object): void
  1267. {
  1268. if (!$this->admin->isChild()) {
  1269. return;
  1270. }
  1271. $parentAdmin = $this->admin->getParent();
  1272. $parentId = $request->get($parentAdmin->getIdParameter());
  1273. \assert(\is_string($parentId) || \is_int($parentId));
  1274. $parentAdminObject = $parentAdmin->getObject($parentId);
  1275. if (null === $parentAdminObject) {
  1276. throw new \RuntimeException(\sprintf(
  1277. 'No object was found in the admin "%s" for the id "%s".',
  1278. $parentAdmin::class,
  1279. $parentId
  1280. ));
  1281. }
  1282. $parentAssociationMapping = $this->admin->getParentAssociationMapping();
  1283. if (null === $parentAssociationMapping) {
  1284. return;
  1285. }
  1286. $propertyAccessor = PropertyAccess::createPropertyAccessor();
  1287. $propertyPath = new PropertyPath($parentAssociationMapping);
  1288. $objectParent = $propertyAccessor->getValue($object, $propertyPath);
  1289. // $objectParent may be an array or a Collection when the parent association is many to many.
  1290. $parentObjectMatches = $this->equalsOrContains($objectParent, $parentAdminObject);
  1291. if (!$parentObjectMatches) {
  1292. throw new \RuntimeException(\sprintf(
  1293. 'There is no association between "%s" and "%s"',
  1294. $parentAdmin->toString($parentAdminObject),
  1295. $this->admin->toString($object)
  1296. ));
  1297. }
  1298. }
  1299. private function setTwigGlobal(string $name, mixed $value): void
  1300. {
  1301. $twig = $this->container->get('twig');
  1302. \assert($twig instanceof Environment);
  1303. try {
  1304. $twig->addGlobal($name, $value);
  1305. } catch (\LogicException) {
  1306. // Variable already set
  1307. }
  1308. }
  1309. private function getBatchActionExecutable(string $action): callable
  1310. {
  1311. $batchActions = $this->admin->getBatchActions();
  1312. if (!\array_key_exists($action, $batchActions)) {
  1313. throw new \RuntimeException(\sprintf('The `%s` batch action is not defined', $action));
  1314. }
  1315. $controller = $batchActions[$action]['controller'] ?? \sprintf(
  1316. '%s::%s',
  1317. $this->admin->getBaseControllerName(),
  1318. \sprintf('batchAction%s', (new UnicodeString($action))->camel()->title(true)->toString())
  1319. );
  1320. // This will throw an exception when called so we know if it's possible or not to call the controller.
  1321. $exists = false !== $this->container
  1322. ->get('controller_resolver')
  1323. ->getController(new Request([], [], ['_controller' => $controller]));
  1324. if (!$exists) {
  1325. throw new \RuntimeException(\sprintf('Controller for action `%s` cannot be resolved', $action));
  1326. }
  1327. return function (ProxyQueryInterface $query, Request $request) use ($controller): Response {
  1328. $request->attributes->set('_controller', $controller);
  1329. $request->attributes->set('query', $query);
  1330. return $this->container->get('http_kernel')->handle($request, HttpKernelInterface::SUB_REQUEST);
  1331. };
  1332. }
  1333. /**
  1334. * Checks whether $needle is equal to $haystack or part of it.
  1335. *
  1336. * @param object|iterable<object> $haystack
  1337. *
  1338. * @return bool true when $haystack equals $needle or $haystack is iterable and contains $needle
  1339. */
  1340. private function equalsOrContains($haystack, object $needle): bool
  1341. {
  1342. if ($needle === $haystack) {
  1343. return true;
  1344. }
  1345. if (is_iterable($haystack)) {
  1346. foreach ($haystack as $haystackItem) {
  1347. if ($haystackItem === $needle) {
  1348. return true;
  1349. }
  1350. }
  1351. }
  1352. return false;
  1353. }
  1354. }