diff --git a/app/src/Controller/Admin/MakeAdminAction.php b/app/src/Controller/Admin/MakeAdminAction.php index 4cafed71..596b831a 100644 --- a/app/src/Controller/Admin/MakeAdminAction.php +++ b/app/src/Controller/Admin/MakeAdminAction.php @@ -4,32 +4,61 @@ use App\Entity\User; use App\Sonata\Admin\UserAdmin; -use Draw\Bundle\SonataExtraBundle\ActionableAdmin\BatchActionInterface; +use Draw\Bundle\SonataExtraBundle\ActionableAdmin\BatchIterator; +use Draw\Bundle\SonataExtraBundle\Notifier\Notification\SonataNotification; +use Sonata\AdminBundle\Admin\AdminInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Notifier\NotifierInterface; -class MakeAdminAction implements BatchActionInterface +class MakeAdminAction { - public function __invoke(UserAdmin $admin, User $user): Response + public function __invoke(UserAdmin $admin, User $user, NotifierInterface $notifier): Response { - $user->setRoles( - array_values( - array_unique( - array_merge( - $user->getRoles(), - ['ROLE_ADMIN'] - ) - ) - ) - ); - - $admin->update($user); + if ($this->addAdminRole($user)) { + $admin->update($user); + $notifier->send(SonataNotification::success('User is now an admin')); + } else { + $notifier->send( + (new SonataNotification('User already has the admin role')) + ->setSonataFlashType('info') + ); + } - return new RedirectResponse($admin->generateUrl('list')); + return new RedirectResponse($admin->generateObjectUrl('show', $user)); } - public function getBatchCallable(): callable + private function addAdminRole(User $user): bool { - return [$this, '__invoke']; + $currentRoles = $user->getRoles(); + + if (\in_array('ROLE_ADMIN', $currentRoles)) { + return false; + } + + $user->setRoles([ + ...$currentRoles, + 'ROLE_ADMIN', + ]); + + return true; + } + + /** + * @param BatchIterator $batchIterator + */ + public function batch(BatchIterator $batchIterator, AdminInterface $admin): Response + { + foreach ($batchIterator->getObjects() as $object) { + if (!$this->addAdminRole($object)) { + $batchIterator->skip('already-admin'); + + continue; + } + + $admin->update($object); + } + + return new RedirectResponse($admin->generateUrl('list')); } } diff --git a/app/src/Sonata/Admin/UserAdmin.php b/app/src/Sonata/Admin/UserAdmin.php index 4a399175..991452bc 100644 --- a/app/src/Sonata/Admin/UserAdmin.php +++ b/app/src/Sonata/Admin/UserAdmin.php @@ -207,6 +207,6 @@ public function getActions(): iterable yield (new AdminAction('makeAdmin', true)) ->setController(MakeAdminAction::class) ->setIcon('fa fa-user-plus') - ->setAllowBatchAction(true); + ->setBatchController(MakeAdminAction::class.'::batch'); } } diff --git a/packages/sonata-extra-bundle/ActionableAdmin/Action/BatchAction.php b/packages/sonata-extra-bundle/ActionableAdmin/Action/BatchAction.php deleted file mode 100644 index a3895489..00000000 --- a/packages/sonata-extra-bundle/ActionableAdmin/Action/BatchAction.php +++ /dev/null @@ -1,66 +0,0 @@ - 'controller_resolver', - ]; - } - - public function __construct( - private ContainerInterface $container - ) { - } - - public function __invoke( - ProxyQuery $query, - ActionableInterface $admin, - Request $request, - ): RedirectResponse { - $query->select('o.id as id'); - - $action = $this->loadAction($admin, $request->request->get('action')); - - $controller = $this->container->get('controller_resolver')->getController( - new Request([], [], ['_controller' => $action->getController()]) - ); - - if (!$controller instanceof BatchActionInterface) { - throw new \RuntimeException(sprintf('Controller "%s" must implement BatchActionInterface', $action->getController())); - } - - foreach ($query->execute() as $id) { - $object = $admin->getObject($id['id']); - - \call_user_func($controller->getBatchCallable(), $admin, $object); - } - - return new RedirectResponse($admin->generateUrl('list')); - } - - private function loadAction(ActionableInterface $admin, string $actionName): AdminAction - { - foreach ($admin->getActions() as $action) { - if ($action->getName() === $actionName) { - return $action; - } - } - - throw new \RuntimeException(sprintf('Action "%s" not found', $actionName)); - } -} diff --git a/packages/sonata-extra-bundle/ActionableAdmin/AdminAction.php b/packages/sonata-extra-bundle/ActionableAdmin/AdminAction.php index 3f164403..71964b5e 100644 --- a/packages/sonata-extra-bundle/ActionableAdmin/AdminAction.php +++ b/packages/sonata-extra-bundle/ActionableAdmin/AdminAction.php @@ -2,7 +2,6 @@ namespace Draw\Bundle\SonataExtraBundle\ActionableAdmin; -use Draw\Bundle\SonataExtraBundle\ActionableAdmin\Action\BatchAction; use Symfony\Component\DependencyInjection\Attribute\Exclude; use Symfony\Component\String\UnicodeString; @@ -15,14 +14,14 @@ class AdminAction '_default' => true, ]; - private bool $allowBatchAction = false; + private ?string $controller = null; - private mixed $controller = null; - - private string $batchController = BatchAction::class; + private ?string $batchController = null; private ?string $icon = null; + private string $urlSuffix; + private string $access; private string|false|null $label; @@ -38,6 +37,11 @@ public function __construct( ->snake() ->upper() ->toString(); + + $this->urlSuffix = (new UnicodeString($this->name)) + ->snake() + ->replace('_', '-') + ->toString(); } public function getName(): string @@ -62,6 +66,18 @@ public function setAccess(string $access): self return $this; } + public function getUrlSuffix(): string + { + return $this->urlSuffix; + } + + public function setUrlSuffix(string $urlSuffix): self + { + $this->urlSuffix = $urlSuffix; + + return $this; + } + public function getLabel(): bool|string|null { return $this->label; @@ -86,12 +102,12 @@ public function setTranslationDomain(bool|string|null $translationDomain): self return $this; } - public function getController(): mixed + public function getController(): string { return $this->controller; } - public function setController(mixed $controller): self + public function setController(string $controller): self { $this->controller = $controller; @@ -122,18 +138,6 @@ public function setIcon(?string $icon): self return $this; } - public function getAllowBatchAction(): bool - { - return $this->allowBatchAction; - } - - public function setAllowBatchAction(bool $allowBatchAction): self - { - $this->allowBatchAction = $allowBatchAction; - - return $this; - } - public function getForEntityListAction(): bool { return $this->forEntityListAction; diff --git a/packages/sonata-extra-bundle/ActionableAdmin/ArgumentResolver/BatchIteratorValueResolver.php b/packages/sonata-extra-bundle/ActionableAdmin/ArgumentResolver/BatchIteratorValueResolver.php new file mode 100644 index 00000000..63190688 --- /dev/null +++ b/packages/sonata-extra-bundle/ActionableAdmin/ArgumentResolver/BatchIteratorValueResolver.php @@ -0,0 +1,56 @@ +getType(); + + if (null === $type) { + return []; + } + + if (BatchIterator::class !== $type && !is_subclass_of($type, BatchIterator::class)) { + return []; + } + + try { + $admin = $this->adminFetcher->get($request); + } catch (\InvalidArgumentException) { + return []; + } + + $action = $request->request->get('action'); + + if (null === $action) { + return []; + } + + foreach ($request->attributes as $attribute) { + if ($attribute instanceof ProxyQuery) { + return [$this->batchIterator->initialize( + query: $attribute, + admin: $admin, + action: $action, + )]; + } + } + + return []; + } +} diff --git a/packages/sonata-extra-bundle/ActionableAdmin/BatchActionInterface.php b/packages/sonata-extra-bundle/ActionableAdmin/BatchActionInterface.php deleted file mode 100644 index c1f70544..00000000 --- a/packages/sonata-extra-bundle/ActionableAdmin/BatchActionInterface.php +++ /dev/null @@ -1,15 +0,0 @@ - + */ + private array $skippedCount = [ + 'undefined' => 0, + 'insufficient-access' => 0, + ]; + + public function __construct( + private BatchNotifier $batchNotifier + ) { + } + + public function initialize(ProxyQuery $query, AdminInterface $admin, string $action): self + { + $batchIterator = clone $this; + + $batchIterator->query = $query; + $batchIterator->admin = $admin; + $batchIterator->action = $action; + + return $batchIterator; + } + + public function getAdmin(): AdminInterface + { + return $this->admin; + } + + /** + * @return iterable + */ + public function getObjects(): iterable + { + $this->query->select('o.id as id'); + + $this->admin->getModelManager(); + foreach ($this->query->execute() as $id) { + $object = $this->admin->getObject($id['id']); + + ++$this->processedCount; + + if ($this->checkObjectAccess && !$this->admin->hasAccess($this->action, $object)) { + $this->skip('insufficient-access'); + + continue; + } + + yield $object; + } + + if ($this->autoNotify) { + $this->notify(); + } + } + + public function getAutoNotify(): bool + { + return $this->autoNotify; + } + + public function setAutoNotify(bool $autoNotify): self + { + $this->autoNotify = $autoNotify; + + return $this; + } + + public function notify(): void + { + $this->batchNotifier->notifyBatch($this); + } + + public function getCheckObjectAccess(): bool + { + return $this->checkObjectAccess; + } + + public function setCheckObjectAccess(bool $checkObjectAccess): self + { + $this->checkObjectAccess = $checkObjectAccess; + + return $this; + } + + public function skip(string $reason = 'undefined'): void + { + --$this->processedCount; + + $this->skippedCount[$reason] = ($this->skippedCount[$reason] ?? 0) + 1; + } + + /** + * @return array + */ + public function getSkippedCount(): array + { + return $this->skippedCount; + } + + public function getProcessedCount(): int + { + return $this->processedCount; + } +} diff --git a/packages/sonata-extra-bundle/ActionableAdmin/BatchNotifier.php b/packages/sonata-extra-bundle/ActionableAdmin/BatchNotifier.php new file mode 100644 index 00000000..5345aff6 --- /dev/null +++ b/packages/sonata-extra-bundle/ActionableAdmin/BatchNotifier.php @@ -0,0 +1,67 @@ +notifier) { + return; + } + + $count = $batchIterator->getProcessedCount(); + + $subject = $this->translator + ->trans( + 'batch.notification.processed', + [ + '%count%' => $count, + ], + 'DrawSonataExtraBundle' + ); + + $this->notifier->send( + (new SonataNotification($subject)) + ->setSonataFlashType(0 === $count ? 'info' : 'success') + ); + + $skipped = $batchIterator->getSkippedCount(); + + $skipped = array_filter( + $skipped, + fn ($value) => 0 !== $value + ); + + asort($skipped); + + $skipped = array_reverse($skipped, true); + + foreach ($skipped as $reason => $count) { + $subject = $this->translator + ->trans( + 'batch.notification.skipped', + [ + '%count%' => $count, + '%reason%' => $this->translator->trans('batch.notification.skipped.reason.'.$reason, [], 'DrawSonataExtraBundle'), + ], + 'DrawSonataExtraBundle' + ); + + $this->notifier->send( + (new SonataNotification($subject)) + ->setSonataFlashType('info') + ); + } + } +} diff --git a/packages/sonata-extra-bundle/ActionableAdmin/Extension/ActionableAdminExtension.php b/packages/sonata-extra-bundle/ActionableAdmin/Extension/ActionableAdminExtension.php index 376ab02a..bffc06e7 100644 --- a/packages/sonata-extra-bundle/ActionableAdmin/Extension/ActionableAdminExtension.php +++ b/packages/sonata-extra-bundle/ActionableAdmin/Extension/ActionableAdminExtension.php @@ -35,8 +35,8 @@ public function configureRoutes(AdminInterface $admin, RouteCollectionInterface } $pattern = $action->getTargetEntity() - ? $admin->getRouterIdParameter().'/'.$action->getName() - : $action->getName(); + ? $admin->getRouterIdParameter().'/'.$action->getUrlSuffix() + : $action->getUrlSuffix(); $collection ->add( @@ -61,7 +61,7 @@ public function getAccessMapping(AdminInterface $admin): array public function configureBatchActions(AdminInterface $admin, array $actions): array { foreach ($this->loadActions($admin) as $adminAction) { - if (!$adminAction->getAllowBatchAction()) { + if (!$batchController = $adminAction->getBatchController()) { continue; } @@ -72,7 +72,7 @@ public function configureBatchActions(AdminInterface $admin, array $actions): ar $actions[$adminAction->getName()] = [ 'label' => $adminAction->getLabel(), 'translation_domain' => $adminAction->getTranslationDomain(), - 'controller' => $adminAction->getBatchController(), + 'controller' => $batchController, ]; } diff --git a/packages/sonata-extra-bundle/Resources/translations/DrawSonataExtraBundle.en.yaml b/packages/sonata-extra-bundle/Resources/translations/DrawSonataExtraBundle.en.yaml new file mode 100644 index 00000000..68c3bb4f --- /dev/null +++ b/packages/sonata-extra-bundle/Resources/translations/DrawSonataExtraBundle.en.yaml @@ -0,0 +1,7 @@ +batch.notification.processed: "{0} No element were processed.|{1} One element was processed.|[2,Inf[ %count% elements were processed." + +batch.notification.skipped: "{1} One element was skipped due to %reason%.|[2,Inf[ %count% elements were skipped due to %reason%." + +batch.notification.skipped.reason: + insufficient-access: 'insufficient access' + undefined: 'undefined reason' \ No newline at end of file diff --git a/translations/DrawSonataExtraBundle.en.yaml b/translations/DrawSonataExtraBundle.en.yaml new file mode 100644 index 00000000..580fd6f8 --- /dev/null +++ b/translations/DrawSonataExtraBundle.en.yaml @@ -0,0 +1,2 @@ +batch.notification.skipped.reason: + already-admin: 'already being an admin' \ No newline at end of file diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml new file mode 100644 index 00000000..6ab6bab3 --- /dev/null +++ b/translations/messages.en.yaml @@ -0,0 +1 @@ +makeAdmin: 'Make Admin' \ No newline at end of file