From a67b041fa32e4b586cf787458db3e0befa8c07d3 Mon Sep 17 00:00:00 2001 From: Greg Ziborov Date: Tue, 15 Nov 2022 15:36:06 +1030 Subject: [PATCH 1/5] Added Oro webhooks to the bundle and Oro 5.0 campatibility --- ...Bundle.php => AligentAsyncEventsBundle.php | 6 +- Async/AbstractRetryableProcessor.php | 10 +- Async/RetryableProcessorInterface.php | 6 +- Async/Topics.php | 27 ++ Async/WebhookEntityProcessor.php | 176 ++++++++++++ Controller/FailedJobController.php | 12 +- .../MassAction/MassRetryActionHandler.php | 7 +- ...on.php => AligentAsyncEventsExtension.php} | 10 +- Entity/FailedJob.php | 7 +- Entity/WebhookTransport.php | 230 +++++++++++++++ EventListener/EntityEventListener.php | 268 ++++++++++++++++++ .../WebhookConfigCacheEventListener.php | 45 +++ Exception/RetryableException.php | 5 +- Form/Type/WebhookHeaderType.php | 42 +++ Form/Type/WebhookTransportSettingsType.php | 154 ++++++++++ Integration/WebhookChannel.php | 26 ++ Integration/WebhookTransport.php | 132 +++++++++ ... => AligentAsyncEventsBundleInstaller.php} | 98 +++---- ...xtField.php => WebhookBundleMigration.php} | 19 +- .../Schema/v1_2/WebhookBundleMigration.php | 34 +++ Provider/WebhookConfigProvider.php | 129 +++++++++ Provider/WebhookIntegrationProvider.php | 85 ++++++ README.md => Readme.md | 55 +++- Resources/config/integration.yml | 13 + Resources/config/oro/actions.yml | 2 +- Resources/config/oro/bundles.yml | 2 +- Resources/config/oro/routing.yml | 2 +- Resources/config/services.yml | 63 +++- Resources/doc/images/webhook-integration.png | Bin 0 -> 52572 bytes Resources/translations/messages.en.yml | 2 + .../AligentAsyncEventsExtensionTest.php | 40 +++ Tests/Unit/Entity/WebhookTransportTest.php | 87 ++++++ .../Type/WebhookTransportSettingsTypeTest.php | 102 +++++++ composer.json | 2 +- 34 files changed, 1792 insertions(+), 106 deletions(-) rename AligentAsyncBundle.php => AligentAsyncEventsBundle.php (74%) create mode 100644 Async/Topics.php create mode 100644 Async/WebhookEntityProcessor.php rename DependencyInjection/{AligentAsyncExtension.php => AligentAsyncEventsExtension.php} (77%) create mode 100644 Entity/WebhookTransport.php create mode 100644 EventListener/EntityEventListener.php create mode 100644 EventListener/WebhookConfigCacheEventListener.php create mode 100644 Form/Type/WebhookHeaderType.php create mode 100644 Form/Type/WebhookTransportSettingsType.php create mode 100644 Integration/WebhookChannel.php create mode 100644 Integration/WebhookTransport.php rename Migrations/Schema/{AligentAsyncBundleInstaller.php => AligentAsyncEventsBundleInstaller.php} (89%) rename Migrations/Schema/v1_1/{UpdateToTextField.php => WebhookBundleMigration.php} (58%) create mode 100644 Migrations/Schema/v1_2/WebhookBundleMigration.php create mode 100644 Provider/WebhookConfigProvider.php create mode 100644 Provider/WebhookIntegrationProvider.php rename README.md => Readme.md (55%) create mode 100644 Resources/config/integration.yml create mode 100644 Resources/doc/images/webhook-integration.png create mode 100644 Tests/Unit/DependencyInjection/AligentAsyncEventsExtensionTest.php create mode 100644 Tests/Unit/Entity/WebhookTransportTest.php create mode 100644 Tests/Unit/Form/Type/WebhookTransportSettingsTypeTest.php diff --git a/AligentAsyncBundle.php b/AligentAsyncEventsBundle.php similarity index 74% rename from AligentAsyncBundle.php rename to AligentAsyncEventsBundle.php index 7ea0667..ee58845 100644 --- a/AligentAsyncBundle.php +++ b/AligentAsyncEventsBundle.php @@ -10,11 +10,11 @@ * @link http://www.aligent.com.au/ */ -namespace Aligent\AsyncBundle; +namespace Aligent\AsyncEventsBundle; use Symfony\Component\HttpKernel\Bundle\Bundle; -class AligentAsyncBundle extends Bundle +class AligentAsyncEventsBundle extends Bundle { -} \ No newline at end of file +} diff --git a/Async/AbstractRetryableProcessor.php b/Async/AbstractRetryableProcessor.php index e1bc3b9..6430fe3 100644 --- a/Async/AbstractRetryableProcessor.php +++ b/Async/AbstractRetryableProcessor.php @@ -10,10 +10,10 @@ * @link http://www.aligent.com.au/ */ -namespace Aligent\AsyncBundle\Async; +namespace Aligent\AsyncEventsBundle\Async; -use Aligent\AsyncBundle\Entity\FailedJob; -use Aligent\AsyncBundle\Exception\RetryableException; +use Aligent\AsyncEventsBundle\Entity\FailedJob; +use Aligent\AsyncEventsBundle\Exception\RetryableException; use Doctrine\ORM\ORMException; use Oro\Component\MessageQueue\Consumption\MessageProcessorInterface; use Oro\Component\MessageQueue\Transport\MessageInterface; @@ -63,7 +63,7 @@ public function process(MessageInterface $message, SessionInterface $session) $this->logger->error( $e->getMessage(), [ - 'processor' => $message->getProperty(Config::PARAMETER_PROCESSOR_NAME), + 'topic' => $message->getProperty(Config::PARAMETER_TOPIC_NAME), 'headers' => $message->getHeaders(), ] ); @@ -114,4 +114,4 @@ protected function handleFailure(MessageInterface $message, RetryableException $ * @throws RetryableException */ abstract public function execute(MessageInterface $message); -} \ No newline at end of file +} diff --git a/Async/RetryableProcessorInterface.php b/Async/RetryableProcessorInterface.php index d325b99..e6ae2b5 100644 --- a/Async/RetryableProcessorInterface.php +++ b/Async/RetryableProcessorInterface.php @@ -10,9 +10,9 @@ * @link http://www.aligent.com.au/ */ -namespace Aligent\AsyncBundle\Async; +namespace Aligent\AsyncEventsBundle\Async; -use Aligent\AsyncBundle\Exception\RetryableException; +use Aligent\AsyncEventsBundle\Exception\RetryableException; use Oro\Component\MessageQueue\Transport\MessageInterface; interface RetryableProcessorInterface @@ -24,4 +24,4 @@ interface RetryableProcessorInterface * @return string */ public function execute(MessageInterface $message); -} \ No newline at end of file +} diff --git a/Async/Topics.php b/Async/Topics.php new file mode 100644 index 0000000..06528d6 --- /dev/null +++ b/Async/Topics.php @@ -0,0 +1,27 @@ + + * @copyright 2020 Aligent Consulting. + * @link http://www.aligent.com.au/ + */ +class Topics +{ + const WEBHOOK_ENTITY_CREATE = 'aligent.webhook.entity.create'; + const WEBHOOK_ENTITY_UPDATE = 'aligent.webhook.entity.update'; + const WEBHOOK_ENTITY_DELETE = 'aligent.webhook.entity.delete'; + + const EVENT_MAP = [ + WebhookConfigProvider::UPDATE => self::WEBHOOK_ENTITY_UPDATE, + WebhookConfigProvider::DELETE => self::WEBHOOK_ENTITY_DELETE, + WebhookConfigProvider::CREATE => self::WEBHOOK_ENTITY_CREATE, + ]; +} diff --git a/Async/WebhookEntityProcessor.php b/Async/WebhookEntityProcessor.php new file mode 100644 index 0000000..b4771f1 --- /dev/null +++ b/Async/WebhookEntityProcessor.php @@ -0,0 +1,176 @@ + + * @copyright 2020 Aligent Consulting. + * @link http://www.aligent.com.au/ + */ +class WebhookEntityProcessor extends AbstractRetryableProcessor implements TopicSubscriberInterface +{ + const EVENT_MAP = [ + Topics::WEBHOOK_ENTITY_UPDATE => WebhookConfigProvider::UPDATE, + Topics::WEBHOOK_ENTITY_DELETE => WebhookConfigProvider::DELETE, + Topics::WEBHOOK_ENTITY_CREATE => WebhookConfigProvider::CREATE, + ]; + + /** + * @var WebhookConfigProvider + */ + protected $configProvider; + + /** + * @var WebhookTransport + */ + protected $transport; + + /** + * @var SerializerInterface + */ + protected $serializer; + + /** + * @param SerializerInterface $serializer + * @return WebhookEntityProcessor + */ + public function setSerializer(SerializerInterface $serializer): WebhookEntityProcessor + { + $this->serializer = $serializer; + + return $this; + } + + /** + * @param WebhookConfigProvider $configProvider + * @return WebhookEntityProcessor + */ + public function setConfigProvider(WebhookConfigProvider $configProvider): WebhookEntityProcessor + { + $this->configProvider = $configProvider; + + return $this; + } + + /** + * @param WebhookTransport $transport + * @return WebhookEntityProcessor + */ + public function setTransport(WebhookTransport $transport): WebhookEntityProcessor + { + $this->transport = $transport; + + return $this; + } + + /** + * @inheritDoc + */ + public function execute(MessageInterface $message) + { + $data = JSON::decode($message->getBody()); + $topic = $message->getProperty(Config::PARAMETER_TOPIC_NAME); + $channelRepo = $this->registry->getRepository(Channel::class); + $channel = $channelRepo->find($data['channelId']); + + if (!$channel) { + $this->logger->critical("Channel {$channel->getName()} no longer exists. Skipping webhook event."); + return self::REJECT; + } + + try { + /** @var WebhookTransportEntity $transport */ + $transport = $channel->getTransport(); + $this->transport->init($transport); + $eventType = self::EVENT_MAP[$topic]; + + $response = $this->transport->sendWebhookEvent( + $transport->getMethod(), + $this->buildPayload($eventType, $data) + ); + $this->logger->info( + 'Webhook sent', + [ + 'eventType' => $eventType, + 'message' => $data, + 'response' => $response + ] + ); + } catch (\Exception $exception) { + throw new RetryableException($exception->getMessage(), $exception->getCode(), $exception); + } catch (GuzzleException $e) { + $message = "Server responded with non-200 status code"; + $this->logger->error( + $message, + [ + 'channelId' => $channel->getId(), + 'channel' => $channel->getName(), + 'topic' => $topic, + 'exception' => $e + ] + ); + throw new RetryableException($message, 0, $e); + } + + return self::ACK; + } + + /** + * @inheritDoc + */ + public static function getSubscribedTopics() + { + return [ + Topics::WEBHOOK_ENTITY_CREATE, + Topics::WEBHOOK_ENTITY_DELETE, + Topics::WEBHOOK_ENTITY_UPDATE, + ]; + } + + /** + * @param string $event + * @param array $data + * @return array + * @throws \Symfony\Component\Serializer\Exception\ExceptionInterface + */ + protected function buildPayload(string $event, array $data) + { + $entity = $this->registry->getRepository($data['class'])->find($data['id']); + + if ($event === WebhookConfigProvider::CREATE) { + $changeSet = []; + } else { + // extract all of the before values from the change set + $changeSet = []; + foreach ($data['changeSet'] as $field => $changes) { + $changeSet[$field] = $changes[0]; + } + } + + $reflClass = new \ReflectionClass($data['class']); + + return [ + 'type' => $reflClass->getShortName(), + 'id' => count($data['id']) > 1 ? $data['id'] : reset($data['id']), + 'operation' => $event, + 'attributes' => $this->serializer->normalize($entity, null, ['webhook']), + 'before' => $changeSet + ]; + } +} diff --git a/Controller/FailedJobController.php b/Controller/FailedJobController.php index 9796b52..1f33b32 100644 --- a/Controller/FailedJobController.php +++ b/Controller/FailedJobController.php @@ -10,19 +10,19 @@ * @link http://www.aligent.com.au/ */ -namespace Aligent\AsyncBundle\Controller; +namespace Aligent\AsyncEventsBundle\Controller; -use Aligent\AsyncBundle\Entity\FailedJob; +use Aligent\AsyncEventsBundle\Entity\FailedJob; use Oro\Bundle\SecurityBundle\Annotation\AclAncestor; use Oro\Component\MessageQueue\Client\MessageProducerInterface; use Oro\Component\MessageQueue\Transport\Exception\Exception; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template; use Oro\Bundle\SecurityBundle\Annotation\Acl; -use Symfony\Bundle\FrameworkBundle\Controller\Controller; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; -class FailedJobController extends Controller +class FailedJobController extends AbstractController { /** * @Route(name="aligent_failed_jobs_index") @@ -71,7 +71,6 @@ public function deleteAction(FailedJob $job) $em->flush(); } catch (Exception $exception) { return new JsonResponse(['successful' => false]); - } return new JsonResponse(['successful' => true]); @@ -102,9 +101,8 @@ public function retryAction(FailedJob $job) $em->flush(); } catch (Exception $exception) { return new JsonResponse(['successful' => false]); - } return new JsonResponse(['successful' => true]); } -} \ No newline at end of file +} diff --git a/Datagrid/Extension/MassAction/MassRetryActionHandler.php b/Datagrid/Extension/MassAction/MassRetryActionHandler.php index 70816e1..5b89a03 100644 --- a/Datagrid/Extension/MassAction/MassRetryActionHandler.php +++ b/Datagrid/Extension/MassAction/MassRetryActionHandler.php @@ -10,10 +10,9 @@ * @link http://www.aligent.com.au/ */ -namespace Aligent\AsyncBundle\Datagrid\Extension\MassAction; +namespace Aligent\AsyncEventsBundle\Datagrid\Extension\MassAction; - -use Aligent\AsyncBundle\Entity\FailedJob; +use Aligent\AsyncEventsBundle\Entity\FailedJob; use Oro\Bundle\DataGridBundle\Extension\MassAction\MassActionHandlerArgs; use Oro\Bundle\DataGridBundle\Extension\MassAction\MassActionHandlerInterface; use Oro\Bundle\DataGridBundle\Extension\MassAction\MassActionResponse; @@ -72,4 +71,4 @@ public function handle(MassActionHandlerArgs $args) ? new MassActionResponse(true, "$count Jobs resent") : new MassActionResponse(false, "$count Jobs resent, $failed jobs failed to be resent"); } -} \ No newline at end of file +} diff --git a/DependencyInjection/AligentAsyncExtension.php b/DependencyInjection/AligentAsyncEventsExtension.php similarity index 77% rename from DependencyInjection/AligentAsyncExtension.php rename to DependencyInjection/AligentAsyncEventsExtension.php index 8e7f266..194608b 100644 --- a/DependencyInjection/AligentAsyncExtension.php +++ b/DependencyInjection/AligentAsyncEventsExtension.php @@ -10,15 +10,16 @@ * @link http://www.aligent.com.au/ */ -namespace Aligent\AsyncBundle\DependencyInjection; +namespace Aligent\AsyncEventsBundle\DependencyInjection; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Extension\Extension; +use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\DependencyInjection\Loader; -class AligentAsyncExtension extends Extension +class AligentAsyncEventsExtension extends Extension { + /** * Loads a specific configuration. * @@ -30,5 +31,6 @@ public function load(array $configs, ContainerBuilder $container) { $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); $loader->load('services.yml'); + $loader->load('integration.yml'); } -} \ No newline at end of file +} diff --git a/Entity/FailedJob.php b/Entity/FailedJob.php index f2dccb7..41610c8 100644 --- a/Entity/FailedJob.php +++ b/Entity/FailedJob.php @@ -10,7 +10,7 @@ * @link http://www.aligent.com.au/ */ -namespace Aligent\AsyncBundle\Entity; +namespace Aligent\AsyncEventsBundle\Entity; use Doctrine\ORM\Mapping as ORM; use Oro\Bundle\EntityBundle\EntityProperty\DatesAwareInterface; @@ -75,8 +75,7 @@ public function __construct( $topic, array $body, \Exception $exception = null - ) - { + ) { $this->topic = $topic; $this->body = $body; @@ -181,4 +180,4 @@ public function setTrace($trace) return $this; } -} \ No newline at end of file +} diff --git a/Entity/WebhookTransport.php b/Entity/WebhookTransport.php new file mode 100644 index 0000000..406f735 --- /dev/null +++ b/Entity/WebhookTransport.php @@ -0,0 +1,230 @@ + + * @copyright 2020 Aligent Consulting. + * @license + * @link http://www.aligent.com.au/ + */ + +namespace Aligent\AsyncEventsBundle\Entity; + +use Oro\Bundle\EntityConfigBundle\Metadata\Annotation\Config; +use Oro\Bundle\IntegrationBundle\Entity\Transport; +use Symfony\Component\HttpFoundation\ParameterBag; +use Doctrine\ORM\Mapping as ORM; + +/** + * Class WebhookTransport + * + * @ORM\Entity + * @Config() + */ +class WebhookTransport extends Transport +{ + /** + * @var string + * @ORM\Column(type="string", name="wh_api_user", nullable=true) + */ + protected $username; + + /** + * @var string + * @ORM\Column(type="string", name="wh_api_key", nullable=true) + */ + protected $password; + + /** + * @var string + * @ORM\Column(type="string", name="wh_api_url", nullable=true) + */ + protected $url; + + /** + * @var string + * @ORM\Column(type="string", name="wh_entity_class", nullable=true) + */ + protected $entity; + + /** + * @var string + * @ORM\Column(type="string", name="wh_event", nullable=true) + */ + protected $event; + + /** + * @var string + * @ORM\Column(type="string", name="wh_method", nullable=true) + */ + protected $method; + + /** + * @var array + * @ORM\Column(type="json", name="wh_headers", nullable=true) + */ + protected $headers = []; + + /** + * @return string + */ + public function getUsername(): ?string + { + return $this->username; + } + + /** + * @param string $username + * @return WebhookTransport + */ + public function setUsername(string $username): WebhookTransport + { + $this->username = $username; + + return $this; + } + + /** + * @return string + */ + public function getPassword(): ?string + { + return $this->password; + } + + /** + * @param string $password + * @return WebhookTransport + */ + public function setPassword(string $password): WebhookTransport + { + $this->password = $password; + + return $this; + } + + /** + * @return string + */ + public function getUrl(): ?string + { + return $this->url; + } + + /** + * @param string $url + * @return WebhookTransport + */ + public function setUrl(string $url): WebhookTransport + { + $this->url = $url; + + return $this; + } + + /** + * @return string + */ + public function getEntity(): ?string + { + return $this->entity; + } + + /** + * @param string $entity + * @return WebhookTransport + */ + public function setEntity(string $entity): WebhookTransport + { + $this->entity = $entity; + + return $this; + } + + /** + * @return string + */ + public function getEvent(): ?string + { + return $this->event; + } + + /** + * @param string $event + * @return WebhookTransport + */ + public function setEvent(string $event): WebhookTransport + { + $this->event = $event; + + return $this; + } + + /** + * @return string + */ + public function getMethod(): ?string + { + return $this->method; + } + + /** + * @param string $method + * @return WebhookTransport + */ + public function setMethod(string $method): WebhookTransport + { + $this->method = $method; + + return $this; + } + + /** + * @return array + */ + public function getHeaders(): ?array + { + return $this->headers; + } + + /** + * @param array $headers + * @return WebhookTransport + */ + public function setHeaders(array $headers): WebhookTransport + { + $this->headers = $headers; + + return $this; + } + + /** + * @param array $header + * @return WebhookTransport + */ + public function addHeader(array $header) + { + $this->headers[] = $header; + return $this; + } + + /** + * @inheritDoc + */ + public function getSettingsBag() + { + return new ParameterBag( + [ + 'username' => $this->getUsername(), + 'password' => $this->getPassword(), + 'url' => $this->getUrl(), + 'entity' => $this->getEntity(), + 'event' => $this->getEvent(), + 'method' => $this->getMethod(), + 'headers' => $this->getHeaders() + ] + ); + } +} diff --git a/EventListener/EntityEventListener.php b/EventListener/EntityEventListener.php new file mode 100644 index 0000000..65654db --- /dev/null +++ b/EventListener/EntityEventListener.php @@ -0,0 +1,268 @@ + + * @copyright 2020 Aligent Consulting. + * @link http://www.aligent.com.au/ + */ +class EntityEventListener implements EventSubscriberInterface, OptionalListenerInterface +{ + /** + * @var WebhookConfigProvider + */ + protected $webhookConfigProvider; + + /** + * @var MessageProducerInterface + */ + protected $producer; + + /** + * @var LoggerInterface + */ + protected $logger; + + /** + * @var \SplObjectStorage + */ + protected $insertions; + + /** + * @var \SplObjectStorage + */ + protected $deletions; + + /** + * @var \SplObjectStorage + */ + protected $updates; + + /** + * @var bool + */ + protected $enabled = true; + + /** + * EntityEventListener constructor. + * @param WebhookConfigProvider $webhookEntityProvider + * @param MessageProducerInterface $producer + * @param LoggerInterface $logger + */ + public function __construct( + WebhookConfigProvider $webhookEntityProvider, + MessageProducerInterface $producer, + LoggerInterface $logger + ) { + $this->webhookConfigProvider = $webhookEntityProvider; + $this->producer = $producer; + $this->logger = $logger; + + $this->insertions = new \SplObjectStorage(); + $this->updates = new \SplObjectStorage(); + $this->deletions = new \SplObjectStorage(); + } + + /** + * @param OnFlushEventArgs $args + */ + public function onFlush(OnFlushEventArgs $args) + { + if (!$this->isEnabled()) { + return; + } + + $em = $args->getEntityManager(); + $this->findWebhookManagedInsertions($em); + $this->findWebhookManagedUpdates($em); + $this->findWebhookManagedDeletions($em); + } + + /** + * @param PostFlushEventArgs $args + * @throws \Oro\Component\MessageQueue\Transport\Exception\Exception + */ + public function postFlush(PostFlushEventArgs $args) + { + if (!$this->isEnabled()) { + return; + } + + $em = $args->getEntityManager(); + try { + $this->queueMessages($this->insertions, $em, WebhookConfigProvider::CREATE); + $this->queueMessages($this->deletions, $em, WebhookConfigProvider::DELETE); + $this->queueMessages($this->updates, $em, WebhookConfigProvider::UPDATE); + } finally { + $this->insertions->detach($em); + $this->deletions->detach($em); + $this->updates->detach($em); + } + } + + /** + * @param EntityManager $em + */ + public function findWebhookManagedInsertions(EntityManagerInterface $em) + { + $uow = $em->getUnitOfWork(); + $insertions = new \SplObjectStorage(); + $scheduledInsertions = $uow->getScheduledEntityInsertions(); + foreach ($scheduledInsertions as $entity) { + if (!$this->webhookConfigProvider->isManaged( + ClassUtils::getClass($entity), + WebhookConfigProvider::CREATE + )) { + continue; + } + + $insertions[$entity] = $uow->getEntityChangeSet($entity); + } + + $this->stashChanges($this->insertions, $em, $insertions); + } + + /** + * @param EntityManager $em + */ + protected function findWebhookManagedUpdates(EntityManagerInterface $em) + { + $uow = $em->getUnitOfWork(); + $updates = new \SplObjectStorage(); + $scheduledUpdates = $uow->getScheduledEntityUpdates(); + foreach ($scheduledUpdates as $entity) { + if (!$this->webhookConfigProvider->isManaged( + ClassUtils::getClass($entity), + WebhookConfigProvider::UPDATE + )) { + continue; + } + + $updates[$entity] = $uow->getEntityChangeSet($entity); + } + + $this->stashChanges($this->updates, $em, $updates); + } + + /** + * @param EntityManager $em + */ + protected function findWebhookManagedDeletions(EntityManagerInterface $em) + { + $uow = $em->getUnitOfWork(); + $deletions = new \SplObjectStorage(); + $scheduledDeletions = $uow->getScheduledEntityDeletions(); + foreach ($scheduledDeletions as $entity) { + if (!$this->webhookConfigProvider->isManaged( + ClassUtils::getClass($entity), + WebhookConfigProvider::DELETE + )) { + continue; + } + + $deletions[$entity] = $uow->getEntityChangeSet($entity); + } + + $this->stashChanges($this->deletions, $em, $deletions); + } + + /** + * @param \SplObjectStorage $storage + * @param EntityManager $em + * @param \SplObjectStorage $changes + */ + protected function stashChanges(\SplObjectStorage $storage, EntityManager $em, \SplObjectStorage $changes) + { + if ($changes->count() > 0) { + if (!$storage->contains($em)) { + $storage[$em] = $changes; + } else { + $previousChangesInCurrentTransaction = $storage[$em]; + $changes->addAll($previousChangesInCurrentTransaction); + $storage[$em] = $changes; + } + } + } + + /** + * @param \SplObjectStorage $storage + * @param EntityManagerInterface $em + * @param string $event + * @throws \Oro\Component\MessageQueue\Transport\Exception\Exception + */ + protected function queueMessages(\SplObjectStorage $storage, EntityManagerInterface $em, string $event) + { + if (!$storage->contains($em)) { + return; + } + + foreach ($storage[$em] as $entity) { + $changeSet = $storage[$em][$entity]; + $class = ClassUtils::getClass($entity); + $metaData = $em->getClassMetadata($class); + $channelIds = $this->webhookConfigProvider->getNotificationChannels($class, $event); + + // queue a job for every channel so they can be retried individually + foreach ($channelIds as $channelId) { + $this->producer->send( + Topics::EVENT_MAP[$event], + new Message( + [ + 'changeSet' => $changeSet, + 'class' => $class, + 'id' => $metaData->getIdentifierValues($entity), + 'channelId' => $channelId + ], + MessagePriority::LOW + ) + ); + } + } + } + + /** + * @inheritDoc + */ + public static function getSubscribedEvents() + { + return [ + 'onFlush', + 'postFlush', + ]; + } + + /** + * @return bool + */ + public function isEnabled() + { + return $this->enabled; //@Todo: Add check for webhook integrations of outgoing type + } + + /** + * @inheritDoc + */ + public function setEnabled($enabled = true) + { + $this->enabled = $enabled; + } +} diff --git a/EventListener/WebhookConfigCacheEventListener.php b/EventListener/WebhookConfigCacheEventListener.php new file mode 100644 index 0000000..398893d --- /dev/null +++ b/EventListener/WebhookConfigCacheEventListener.php @@ -0,0 +1,45 @@ + + * @copyright 2020 Aligent Consulting. + * @link http://www.aligent.com.au/ + */ +class WebhookConfigCacheEventListener +{ + /** + * @var CacheProvider + */ + protected $cache; + + /** + * WebhookConfigCacheEventListener constructor. + * @param CacheProvider $cache + */ + public function __construct(CacheProvider $cache) + { + $this->cache = $cache; + } + + /** + * @param Transport $transport + * @param LifecycleEventArgs $args + */ + public function postUpdate(Transport $transport, LifecycleEventArgs $args) + { + if ($transport instanceof WebhookTransport) { + $this->cache->deleteAll(); + } + } +} diff --git a/Exception/RetryableException.php b/Exception/RetryableException.php index c043130..b7e1294 100644 --- a/Exception/RetryableException.php +++ b/Exception/RetryableException.php @@ -10,10 +10,9 @@ * @link http://www.aligent.com.au/ */ -namespace Aligent\AsyncBundle\Exception; - +namespace Aligent\AsyncEventsBundle\Exception; class RetryableException extends \Exception { -} \ No newline at end of file +} diff --git a/Form/Type/WebhookHeaderType.php b/Form/Type/WebhookHeaderType.php new file mode 100644 index 0000000..1273372 --- /dev/null +++ b/Form/Type/WebhookHeaderType.php @@ -0,0 +1,42 @@ + + * @copyright 2020 Aligent Consulting. + * @link http://www.aligent.com.au/ + */ +class WebhookHeaderType extends AbstractType +{ + + /** + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->add( + 'header', + TextType::class, + [ + 'required' => true, + + ] + )->add( + 'value', + TextType::class, + [ + 'required' => true + ] + ); + } +} diff --git a/Form/Type/WebhookTransportSettingsType.php b/Form/Type/WebhookTransportSettingsType.php new file mode 100644 index 0000000..d048e0d --- /dev/null +++ b/Form/Type/WebhookTransportSettingsType.php @@ -0,0 +1,154 @@ + + * @copyright 2020 Aligent Consulting. + * @license + * @link http://www.aligent.com.au/ + */ + +namespace Aligent\AsyncEventsBundle\Form\Type; + +use Aligent\AsyncEventsBundle\Entity\WebhookTransport; +use Aligent\AsyncEventsBundle\Provider\WebhookConfigProvider; +use Doctrine\Persistence\ManagerRegistry; +use Oro\Bundle\FormBundle\Form\Type\CollectionType; +use Oro\Bundle\FormBundle\Form\Type\OroChoiceType; +use Oro\Bundle\FormBundle\Form\Type\OroEncodedPlaceholderPasswordType; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\Extension\Core\Type\UrlType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Validator\Constraints as Assert; + +class WebhookTransportSettingsType extends AbstractType +{ + /** + * @var ManagerRegistry + */ + protected $registry; + + /** + * WebhookTransportSettingsType constructor. + * @param ManagerRegistry $registry + */ + public function __construct(ManagerRegistry $registry) + { + $this->registry = $registry; + } + + /** + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->add( + 'username', + TextType::class, + [ + 'required' => true, + 'constraints' => [ + new Assert\NotBlank(), + ], + ] + ) + ->add( + 'password', + OroEncodedPlaceholderPasswordType::class, + [ + 'required' => true, + 'constraints' => [ + new Assert\NotBlank(), + ], + 'browser_autocomplete' => true, + ] + ) + ->add( + 'entity', + OroChoiceType::class, + [ + 'required' => true, + 'choices' => $this->getEntityList() + ] + ) + ->add( + 'event', + OroChoiceType::class, + [ + 'required' => true, + 'choices' => $this->getEventsList() + ] + ) + ->add( + 'method', + ChoiceType::class, + [ + 'required' => true, + 'choices' => [ + 'POST' => 'POST', + 'PUT' => 'PUT', + 'PATCH' => 'PATCH', + 'DELETE' => 'DELETE' + ] + ] + ) + ->add( + 'url', + UrlType::class, + [ + 'required' => true + ] + ) + ->add( + 'headers', + CollectionType::class, + [ + 'entry_type' => WebhookHeaderType::class, + 'handle_primary' => false + ] + ); + } + + /** + * @return array + */ + protected function getEntityList() + { + $em = $this->registry->getManager(); + $entities = []; + + foreach ($em->getMetadataFactory()->getAllMetadata() as $metadata) { + $class = $metadata->getName(); + $entities[$class] = $class; + } + + return $entities; + } + + /** + * @return array + */ + protected function getEventsList() + { + $events = []; + $events[WebhookConfigProvider::CREATE] = WebhookConfigProvider::CREATE; + $events[WebhookConfigProvider::UPDATE] = WebhookConfigProvider::UPDATE; + $events[WebhookConfigProvider::DELETE] = WebhookConfigProvider::DELETE; + + return $events; + } + /** + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefault('data_class', WebhookTransport::class); + } +} diff --git a/Integration/WebhookChannel.php b/Integration/WebhookChannel.php new file mode 100644 index 0000000..1c7ae31 --- /dev/null +++ b/Integration/WebhookChannel.php @@ -0,0 +1,26 @@ + + * @copyright 2020 Aligent Consulting. + * @license + * @link http://www.aligent.com.au/ + */ + +namespace Aligent\AsyncEventsBundle\Integration; + +use Oro\Bundle\IntegrationBundle\Provider\ChannelInterface; + +class WebhookChannel implements ChannelInterface +{ + /** + * @inheritDoc + */ + public function getLabel() + { + return 'aligent.webhook.channel.label'; + } +} diff --git a/Integration/WebhookTransport.php b/Integration/WebhookTransport.php new file mode 100644 index 0000000..4d9830b --- /dev/null +++ b/Integration/WebhookTransport.php @@ -0,0 +1,132 @@ + + * @copyright 2020 Aligent Consulting. + * @license + * @link http://www.aligent.com.au/ + */ + +namespace Aligent\AsyncEventsBundle\Integration; + +use Aligent\AsyncEventsBundle\Form\Type\WebhookTransportSettingsType; +use GuzzleHttp\Client; +use Oro\Bundle\IntegrationBundle\Entity\Channel; +use Oro\Bundle\IntegrationBundle\Entity\Transport; +use Oro\Bundle\IntegrationBundle\Provider\TransportInterface; +use Oro\Bundle\SecurityBundle\Encoder\SymmetricCrypterInterface; +use Psr\Log\LoggerInterface; + +class WebhookTransport implements TransportInterface +{ + /** + * @var SymmetricCrypterInterface + */ + protected $encoder; + + /** + * @var LoggerInterface + */ + protected $logger; + + /** + * @var Channel + */ + protected $channel; + + /** + * @var Client + */ + protected $client; + + /** + * AntavoRestTransport constructor. + * @param SymmetricCrypterInterface $encoder + * @param LoggerInterface $logger + */ + public function __construct(SymmetricCrypterInterface $encoder, LoggerInterface $logger) + { + $this->encoder = $encoder; + $this->logger = $logger; + } + + /** + * @inheritDoc + */ + public function init(Transport $transportEntity) + { + $settings = $transportEntity->getSettingsBag(); + $this->channel = $transportEntity->getChannel(); + + // Decode the Secret key + $settings->set( + 'password', + $this->encoder->decryptData($settings->get('password')) + ); + + $headers = array_column($settings->get('headers'), 'value', 'header'); + + $this->client = new Client( + [ + 'base_uri' => $settings->get('url'), + 'allow_redirects' => false, + 'auth' => [ + $settings->get('username'), + $settings->get('password') + ], + 'headers' => $headers + ] + ); + } + + /** + * @param string $method + * @param array $payload + * @return \Psr\Http\Message\ResponseInterface + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function sendWebhookEvent($method = 'POST', $payload = []) + { + return $this->client->request( + $method, + [ + 'json' => $payload + ] + ); + } + + /** + * @inheritDoc + */ + public function getLabel() + { + return 'aligent.webhook.transport.label'; + } + + /** + * @inheritDoc + */ + public function getSettingsFormType() + { + return WebhookTransportSettingsType::class; + } + + /** + * @inheritDoc + */ + public function getSettingsEntityFQCN() + { + return \Aligent\AsyncEventsBundle\Entity\WebhookTransport::class; + } + + /** + * @return Channel + */ + public function getChannel(): Channel + { + return $this->channel; + } +} diff --git a/Migrations/Schema/AligentAsyncBundleInstaller.php b/Migrations/Schema/AligentAsyncEventsBundleInstaller.php similarity index 89% rename from Migrations/Schema/AligentAsyncBundleInstaller.php rename to Migrations/Schema/AligentAsyncEventsBundleInstaller.php index 4dd331a..c060761 100644 --- a/Migrations/Schema/AligentAsyncBundleInstaller.php +++ b/Migrations/Schema/AligentAsyncEventsBundleInstaller.php @@ -1,49 +1,49 @@ -createAligentFailedJobTable($schema); - } - - /** - * Create aligent_failed_job table - * - * @param Schema $schema - */ - protected function createAligentFailedJobTable(Schema $schema) - { - $table = $schema->createTable('aligent_failed_job'); - $table->addColumn('id', 'integer', ['autoincrement' => true]); - $table->addColumn('topic', 'string', ['length' => 255]); - $table->addColumn('body', 'json_array', ['comment' => '(DC2Type:json_array)']); - $table->addColumn('exception', 'string', ['notnull' => false, 'length' => 255]); - $table->addColumn('trace', 'text', ['notnull' => false]); - $table->addColumn('created_at', 'datetime', ['comment' => '(DC2Type:datetime)']); - $table->addColumn('updated_at', 'datetime', ['comment' => '(DC2Type:datetime)']); - $table->setPrimaryKey(['id']); - } -} +createAligentFailedJobTable($schema); + } + + /** + * Create aligent_failed_job table + * + * @param Schema $schema + */ + protected function createAligentFailedJobTable(Schema $schema) + { + $table = $schema->createTable('aligent_failed_job'); + $table->addColumn('id', 'integer', ['autoincrement' => true]); + $table->addColumn('topic', 'string', ['length' => 255]); + $table->addColumn('body', 'json_array', ['comment' => '(DC2Type:json_array)']); + $table->addColumn('exception', 'string', ['notnull' => false, 'length' => 255]); + $table->addColumn('trace', 'text', ['notnull' => false]); + $table->addColumn('created_at', 'datetime', ['comment' => '(DC2Type:datetime)']); + $table->addColumn('updated_at', 'datetime', ['comment' => '(DC2Type:datetime)']); + $table->setPrimaryKey(['id']); + } +} diff --git a/Migrations/Schema/v1_1/UpdateToTextField.php b/Migrations/Schema/v1_1/WebhookBundleMigration.php similarity index 58% rename from Migrations/Schema/v1_1/UpdateToTextField.php rename to Migrations/Schema/v1_1/WebhookBundleMigration.php index 0cfef4c..22fe2f5 100644 --- a/Migrations/Schema/v1_1/UpdateToTextField.php +++ b/Migrations/Schema/v1_1/WebhookBundleMigration.php @@ -1,30 +1,25 @@ * @copyright 2020 Aligent Consulting. * @link http://www.aligent.com.au/ */ -class UpdateToTextField implements Migration +class WebhookBundleMigration implements Migration { /** - * Modifies the given schema to apply necessary changes of a database - * The given query bag can be used to apply additional SQL queries before and after schema changes - * - * @param Schema $schema - * @param QueryBag $queries - * @return void + * @inheritDoc */ public function up(Schema $schema, QueryBag $queries) { @@ -36,4 +31,4 @@ public function up(Schema $schema, QueryBag $queries) ] ); } -} \ No newline at end of file +} diff --git a/Migrations/Schema/v1_2/WebhookBundleMigration.php b/Migrations/Schema/v1_2/WebhookBundleMigration.php new file mode 100644 index 0000000..16e0b18 --- /dev/null +++ b/Migrations/Schema/v1_2/WebhookBundleMigration.php @@ -0,0 +1,34 @@ + + * @copyright 2020 Aligent Consulting. + * @link http://www.aligent.com.au/ + */ +class WebhookBundleMigration implements Migration +{ + /** + * @inheritDoc + */ + public function up(Schema $schema, QueryBag $queries) + { + $table = $schema->getTable('oro_integration_transport'); + $table->addColumn('wh_entity_class', 'string', ['notnull' => false, 'length' => 255]); + $table->addColumn('wh_event', 'string', ['notnull' => false, 'length' => 16]); + $table->addColumn('wh_method', 'string', ['notnull' => false, 'length' => 16]); + $table->addColumn('wh_headers', 'json', ['notnull' => false]); + $table->addColumn('wh_api_url', 'string', ['notnull' => false]); + $table->addColumn('wh_api_key', 'string', ['notnull' => false]); + $table->addColumn('wh_api_user', 'string', ['notnull' => false]); + } +} diff --git a/Provider/WebhookConfigProvider.php b/Provider/WebhookConfigProvider.php new file mode 100644 index 0000000..2f616ef --- /dev/null +++ b/Provider/WebhookConfigProvider.php @@ -0,0 +1,129 @@ + + * @copyright 2020 Aligent Consulting. + * @link http://www.aligent.com.au/ + */ +class WebhookConfigProvider +{ + const CREATE = 'create'; + const UPDATE = 'update'; + const DELETE = 'delete'; + + // Cache Keys + const CONFIG_CACHE_KEY = 'WebhookConfig'; + + /** + * @var ManagerRegistry + */ + protected $registry; + + /** + * @var CacheProvider + */ + protected $cache; + + /** + * WebhookEntityProvider constructor. + * @param ManagerRegistry $registry + * @param CacheProvider $cache + */ + public function __construct(ManagerRegistry $registry, CacheProvider $cache) + { + $this->registry = $registry; + $this->cache = $cache; + } + + /** + * Initialize the webhook config cache + */ + protected function getWebhookConfig() + { + if ($webhookConfig = $this->cache->fetch(self::CONFIG_CACHE_KEY)) { + return $webhookConfig; + } + + // Get All enabled webhook integrations + $repo = $this->registry->getRepository(Channel::class); + $channels = $repo->findBy( + [ + 'enabled' => true, + 'type' => 'webhook', + ] + ); + + $config = []; + foreach ($channels as $channel) { + $config[$channel->getId()] = $channel->getTransport(); + } + + $config = $this->normalizeConfig($config); + $this->cache->save(static::CONFIG_CACHE_KEY, $config); + return $config; + } + + /** + * @param $class + * @return array|null + */ + public function getEntityConfig($class) + { + $config = $this->getWebhookConfig(); + return $config[$class] ?? null; + } + + /** + * @param $class + * @param $event + * @return boolean + */ + public function isManaged($class, $event) + { + $entityConfig = $this->getEntityConfig($class); + if (!$entityConfig) { + return false; + } + + return isset($entityConfig[$event]); + } + + /** + * Returns a list of channel id's that wish to be notified of this event for this entity + * @param $class + * @param $event + * @return int[] + */ + public function getNotificationChannels($class, $event) + { + $entityConfig = $this->getEntityConfig($class); + return $entityConfig[$event]; + } + + /** + * Convert the webhook config to an easily queried format + * @param array $config + * @return array + */ + protected function normalizeConfig(array $config) + { + $normalizedConfig = []; + foreach ($config as $id => $webhookTransport) { + $class = $webhookTransport->getEntity(); + $normalizedConfig[$class][$webhookTransport->getEvent()] = [$id]; + $normalizedConfig[$class]['channels'][] = $id; + } + + return $normalizedConfig; + } +} diff --git a/Provider/WebhookIntegrationProvider.php b/Provider/WebhookIntegrationProvider.php new file mode 100644 index 0000000..83bfee4 --- /dev/null +++ b/Provider/WebhookIntegrationProvider.php @@ -0,0 +1,85 @@ + + * @copyright 2020 Aligent Consulting. + * @license + * @link http://www.aligent.com.au/ + */ + +namespace Aligent\AsyncEventsBundle\Provider; + +use Aligent\AsyncEventsBundle\Entity\WebhookTransport as WebhookTransportSettings; +use Aligent\AsyncEventsBundle\Integration\WebhookTransport; +use Doctrine\Persistence\ObjectRepository; +use Symfony\Bridge\Doctrine\ManagerRegistry; + +class WebhookIntegrationProvider +{ + /** + * @var ManagerRegistry + */ + protected $registry; + + /** + * @var WebhookTransport + */ + protected $transport; + + /** + * WebhookIntegrationProvider constructor. + * @param ManagerRegistry $registry + * @param WebhookTransport $transport + */ + public function __construct( + ManagerRegistry $registry, + WebhookTransport $transport + ) { + $this->registry = $registry; + $this->transport = $transport; + } + + /** + * @param $username + * @return object|null + */ + public function getTransportByUsername($username): ?object + { + $repo = $this->getTransportRepo(); + + return $repo->findOneBy( + [ + 'username' => $username + ] + ); + } + + /** + * @return ObjectRepository + */ + protected function getTransportRepo(): ObjectRepository + { + return $this->registry->getRepository(WebhookTransport::class); + } + + /** + * @param WebhookTransportSettings $transportEntity + * @return WebhookTransport + */ + public function initializeTransport(WebhookTransportSettings $transportEntity): WebhookTransport + { + $this->transport->init($transportEntity); + return $this->transport; + } + + /** + * @return WebhookTransport + */ + public function getTransport(): WebhookTransport + { + return $this->transport; + } +} diff --git a/README.md b/Readme.md similarity index 55% rename from README.md rename to Readme.md index 086e942..5f7499e 100644 --- a/README.md +++ b/Readme.md @@ -1,7 +1,11 @@ -Aligent Async Bundle +Aligent Async Events Bundle --- -This bundle provides an Abstract Processor class that will catch RetryableExceptions and after 3 (default) retries place +This bundle provides: + +1. An Abstract Processor class that will catch RetryableExceptions and after 3 (default) retries place the failed jobs into the database so they can be reviewed, fixed and then requeued. + +2. Webhook integration Installation Instructions ------------------------- @@ -47,7 +51,7 @@ class TestJobProcessor extends AbstractRetryableProcessor } ``` -By default the AbstractRetryableProcessor will fetch the passed exception from the RetryableException and store the trace. +By default the AbstractRetryableProcessor will fetch the passed exception from the RetryableException and store the trace. However if you aren't catching an exception you can just use the RetryableException and it will store the trace and message generated at this point. ``` @@ -74,6 +78,51 @@ class TestJobProcessor extends AbstractRetryableProcessor You can also increase or decrease the amount of retries by overriding `const MAX_RETRIES = 3;` in your own class. +Set up Transport for Webhooks +----------- +Go to the "System -> Integrations -> Manage integrations" and click "Create Integration". Select "Webhook" as the integration type and fill all required fields. + +To Enable set status as Active. + +![Webhook Integration Form](Resources/doc/images/webhook-integration.png?raw=true "Webhook Integration Form") + +Once complete you must now receive webhook events to requested URL. + +Dispatch webhook events +----------- + +1. EntityEventListener listens to any create/update/delete events; + check if relevant webhook transport exists and active; + if yes, pushes message to the queue + +``` +Aligent\AsyncEventsBundle\EventListener\EntityEventListener: +lazy: true +arguments: +- '@Aligent\AsyncEventsBundle\Provider\WebhookConfigProvider' +- '@oro_message_queue.client.message_producer' +- '@logger' +tags: +- { name: doctrine.event_listener, event: onFlush } +- { name: doctrine.event_listener, event: postFlush } +``` + +2. WebhookEntityProcessor builds a payload and send a webhook event in response to the queued message. + +``` +Aligent\AsyncEventsBundle\Async\WebhookEntityProcessor: +parent: Aligent\AsyncEventsBundle\Async\AbstractRetryableProcessor +calls: +- [setConfigProvider, ['@Aligent\AsyncEventsBundle\Provider\WebhookConfigProvider']] +- [setTransport, ['@Aligent\AsyncEventsBundle\Integration\WebhookTransport']] +- [setSerializer, ['@oro_importexport.serializer']] +tags: +- { name: 'oro_message_queue.client.message_processor' } +``` + +3. If you need to enrich payload data (ex., add addreses for the customer user), + you can use OroImportExportBundle and add some custom normalizers to add/transform payload data. + Support ------- If you have any issues with this bundle, please create a [GitHub issue](https://github.com/aligent/oro-async-bundle/issues). diff --git a/Resources/config/integration.yml b/Resources/config/integration.yml new file mode 100644 index 0000000..017126f --- /dev/null +++ b/Resources/config/integration.yml @@ -0,0 +1,13 @@ +services: + Aligent\AsyncEventsBundle\Integration\WebhookChannel: + class: Aligent\AsyncEventsBundle\Integration\WebhookChannel + tags: + - { name: oro_integration.channel, type: webhook } + + Aligent\AsyncEventsBundle\Integration\WebhookTransport: + class: Aligent\AsyncEventsBundle\Integration\WebhookTransport + arguments: + - "@oro_security.encoder.default" + - '@logger' + tags: + - { name: oro_integration.transport, channel_type: webhook, type: webhook } \ No newline at end of file diff --git a/Resources/config/oro/actions.yml b/Resources/config/oro/actions.yml index 2e97599..56929c4 100644 --- a/Resources/config/oro/actions.yml +++ b/Resources/config/oro/actions.yml @@ -9,7 +9,7 @@ operations: mass_action: type: retryjobs label: aligent.async.failedjob.mass_retry.label - handler: Aligent\AsyncBundle\Datagrid\Extension\MassAction\MassRetryActionHandler + handler: Aligent\AsyncEventsBundle\Datagrid\Extension\MassAction\MassRetryActionHandler acl_resource: failed_jobs icon: refresh data_identifier: job.id diff --git a/Resources/config/oro/bundles.yml b/Resources/config/oro/bundles.yml index 09ab3df..ee5b0f1 100644 --- a/Resources/config/oro/bundles.yml +++ b/Resources/config/oro/bundles.yml @@ -1,2 +1,2 @@ bundles: - - { name: Aligent\AsyncBundle\AligentAsyncBundle, priority: 50 } + - { name: Aligent\AsyncEventsBundle\AligentAsyncEventsBundle, priority: 50 } diff --git a/Resources/config/oro/routing.yml b/Resources/config/oro/routing.yml index 6447016..8893d13 100644 --- a/Resources/config/oro/routing.yml +++ b/Resources/config/oro/routing.yml @@ -1,4 +1,4 @@ aligent_failed_jobs: - resource: '@AligentAsyncBundle/Controller/FailedJobController.php' + resource: '@AligentAsyncEventsBundle/Controller/FailedJobController.php' type: annotation prefix: /failedJobs \ No newline at end of file diff --git a/Resources/config/services.yml b/Resources/config/services.yml index 5038323..31d69ef 100644 --- a/Resources/config/services.yml +++ b/Resources/config/services.yml @@ -1,19 +1,72 @@ services: - Aligent\AsyncBundle\Async\AbstractRetryableProcessor: - class: Aligent\AsyncBundle\Async\AbstractRetryableProcessor + Aligent\AsyncEventsBundle\Provider\WebhookIntegrationProvider: + class: Aligent\AsyncEventsBundle\Provider\WebhookIntegrationProvider + public: true + arguments: + - '@doctrine' + - '@Aligent\AsyncEventsBundle\Integration\WebhookTransport' + + # Custom Form Types + Aligent\AsyncEventsBundle\Form\Type\WebhookTransportSettingsType: + arguments: + - '@doctrine' + tags: + - { name: form.type } + + # Caches + aligent_webhook.config.cache: + public: false + parent: oro.cache.abstract + calls: + - [setNamespace, ['aligent_webhook']] + + # Providers + Aligent\AsyncEventsBundle\Provider\WebhookConfigProvider: + arguments: + - '@doctrine' + - '@aligent_webhook.config.cache' + + # Event Listeners + Aligent\AsyncEventsBundle\EventListener\EntityEventListener: + lazy: true + arguments: + - '@Aligent\AsyncEventsBundle\Provider\WebhookConfigProvider' + - '@oro_message_queue.client.message_producer' + - '@logger' + tags: + - { name: doctrine.event_listener, event: onFlush } + - { name: doctrine.event_listener, event: postFlush } + + Aligent\AsyncEventsBundle\EventListener\WebhookConfigCacheEventListener: + arguments: + - '@aligent_webhook.config.cache' + tags: + - { name: doctrine.orm.entity_listener, entity: 'Oro\Bundle\IntegrationBundle\Entity\Transport', event: postUpdate } + + Aligent\AsyncEventsBundle\Async\WebhookEntityProcessor: + parent: Aligent\AsyncEventsBundle\Async\AbstractRetryableProcessor + calls: + - [setConfigProvider, ['@Aligent\AsyncEventsBundle\Provider\WebhookConfigProvider']] + - [setTransport, ['@Aligent\AsyncEventsBundle\Integration\WebhookTransport']] + - [setSerializer, ['@oro_importexport.serializer']] + tags: + - { name: 'oro_message_queue.client.message_processor' } + + Aligent\AsyncEventsBundle\Async\AbstractRetryableProcessor: + class: Aligent\AsyncEventsBundle\Async\AbstractRetryableProcessor arguments: - '@logger' - '@doctrine' - Aligent\AsyncBundle\Datagrid\Extension\MassAction\Ajax\AjaxMassRetryJobsAction: + Aligent\AsyncEventsBundle\Datagrid\Extension\MassAction\Ajax\AjaxMassRetryJobsAction: class: Oro\Bundle\DataGridBundle\Extension\MassAction\Actions\Ajax\AjaxMassAction shared: false public: true tags: - { name: oro_datagrid.extension.mass_action.type, type: retryjobs } - Aligent\AsyncBundle\Datagrid\Extension\MassAction\MassRetryActionHandler: - class: Aligent\AsyncBundle\Datagrid\Extension\MassAction\MassRetryActionHandler + Aligent\AsyncEventsBundle\Datagrid\Extension\MassAction\MassRetryActionHandler: + class: Aligent\AsyncEventsBundle\Datagrid\Extension\MassAction\MassRetryActionHandler public: true arguments: - '@oro_message_queue.message_producer' \ No newline at end of file diff --git a/Resources/doc/images/webhook-integration.png b/Resources/doc/images/webhook-integration.png new file mode 100644 index 0000000000000000000000000000000000000000..338acd93a394376ae36357abfd5941a91e70e713 GIT binary patch literal 52572 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

Mu6& z@NixN6v`PXUCe|SSjhQTxSnFCJb+p@K4L<>$HTGKpHRn}1U9J1a}N9?{Asc1K^e-m zFQ7*6)CeEfBff%#MaR|^)yi+HRGGeeD{Od@6lCB)Aoh4$J1eZ*DufK5#G-AC_QP`L z^UmuZ;xBbbGA1^BnA8tm_sx#Z#ujw|4?_&)s@~h6kEvV2-kQz-83QYq$`8{}XX}!N zru8%P=zcRM14{41kNU3LbG_TkU>4l7U~t#CIF^7MH?%rvPceAFay?X{01tHE6^r&z zb9K*#m(`z-_XhtjOU-!7qdzSWYplSejx}edub88=e3Zg=6LH}h8Y-NK0YWZC7N>Nz z&bi@|p-1L}OMgv*Snv+#f{N4}Mg|TN(h3McV%#bABJ&e|*08bG4;hm8DV*%!RGnQH zME3?z@o~~ShnP%JJRG6S=KTVkBgclj1LFq71y~+va1>UX)&>Ql+>Nf|D=RY39bV%; z!C^6E$|O_c((nYQY0ur7W}}_D*UtFx8k+ZZN=BBQW|A0~;`Zmg zb_^Tf&4^FKU6vM9eXIoo|Dh*vGQ`dk9@=9yl+|t&2i3gKSaClKG|NIQzT@KXDAd}z zDv0-RcbS%6&D6xyeSit7b&z4k)|$?B?GuB``KP)kJ%>obHjI+IsWD?`oSpX4`%}kC zTlUN|=Fx6B$NP<@Uf+D*2d^c~`{6wlV&2McLe;5~Z*PC{IL^iIZn%gRHgzm59VGry z%0&6HRPAvtWTvKe8iU8y-bmqtb=Y7DFVpBHp>C_@>UI9ruPn&4gM@%0qK-XzWZpSO zIa|`wfJ3`yc2DE}Pd!|ok+%yOZ65n68nLKUqdMQ($t^~6n97agY5POO&`cyYu4gW> z8;`Wx_XHj0V_$UMCZ^e<)7qqK8h@Q$ZlNfG4Fz^wjErSiB^P1?E+Mxo?n1WU@&b<2BpB~g+EAS!nmw+6<V`3=B?z$yIgSChMOrgcv1JZQ3DAHvr_NA%1hdQC=)B1`W5j^ z1n}djcFWynndQG7C7`%-us*pH7#y;gk}!*!Gfn#%sHGw&SH04G*wL1&f_JFv&ReWZ znzQ6j-_9%(`L6y2(r@(6JWxK6!DIA9s<9uai+=bw0>DztfA6flk2)xP+QU_K5l3q1AY*;VC|SZ>rQ=db7?BpgR|6;Z*!5WiQSa=FwPCLmUSjzmgU zN{ZKD1QJ>&HOH9x<+R39%Y+lZ`C-{``3}p7GB&4mxQRS_M&Fv$B%I-BW}uP;d%$nM zAr+yi=JXi!qfjRsW4#Qi_;rNJq;%G~&CnNc&lh$-M>X+wP6avimF&Af;f_e78CO|c z5pRvbW=8I_cIKpvj#DBUVefujJ4UvKi%%?Ql01dPi1>YMssox(GO`t53KY9s>8esF zAJvWd@~D^Und;=-C&;5N1qK(`B{NB~)B4Yse_-$HI~rnh2%B&wFe^T~?cxzJm9h*T z(`R+$V{r&N0Ch|Y7d({Oa=WzCxSm4mwA-sMfOa@}Wy1T}$nP_8HdYr5;#=Ksg1ZqY z;l1&i4zgZA(c(l!1&*tlQ^UDsRZ_TbSD`YXzv(!-o8CBs8 z*JetpC~8%KZ~SBw)?>!5!4T`-vxuI*#M~qYMc`ackpapAM z6e?8~F1$^I`4RsJbhd+mHWrhbkdUHh*g+8!F2%;;Hb(DjH59kKmikt({ib_&rPzWP znb*Cy;hRr?Qr?J?sfPYmgF8MDxDom8rhs_lY0!M<;7zeIqgsgvd%E$3C!EjLEe- zw{3nS86_EI?tppc%n8(wtt;zhe{G!(9kGcNTMbc?v&=9d>5sxxjr^JGTfAnWr;&E~ zXd+;L(X2WamT0vSj(~^Zd@LYv-TFTGb`#Y~g6CWA_3geo`lWlmFzo(vjN$)F&k zQVeUXcF5=~6~3*tnI0;3h#RPve~o&76akESYZ>5`7y&MJs>_0qQSx}qBWI>3q}soT zzG~2=pT!}TnAzPLb7NzbnRc45obDA9GgHPwXp7CSgFiy~V3d^nv+M087#!CZm#-)$ zMR8_QoJC=#O0&X|DuCk2yyYMmx$^zDOoaA!{!^s3WjR~op0h>PK8k#M^@)jI668J# z4Gm*%`E(N?b~mxg@x@jSQ0sS}nW+N3 zSH%fr8%)V03o!|X`0Ij{GkT_MhaZRs_|xx%7PmPLyJtU(BfO&CFCCU`r{YN|3Kc7U zqGfOW2XKgVkf+l@a~kwL;X>W2YEm0_M^!K$-*u#`?#$3}2Aw~R-qFN8ff<{71pJ_SWt(|^)w#o-WC@3g_jJ(`TXOY*JUii4|Zysn= z>ET1_P0v3p4yL6P@A!xWRwiQtjDwt()*5WI*|lh=Rw=J&0PTO2$N?c$)Q2k8-3zNZgYRf~sx)VPtZ=z{x6TOgf+W63$nuKM2#ttPEd^*kw+r*l+#jr;_nz{yf*yQdQkG?%S>nV+vm z+{qa3Wz|osxZ`nV^hfS=*Wj1tev^S?Hx|`JZ}Mjmm7HJR-jYyKW4T3bmW=Gs);1%Y78t#2n|FkuoDQ~6AKU#Gc}Xj{ z=(BuzD&wv4G&$@y&t(2=YkPHZ& zn(Nt7wVES%4NP86CCwMhB?T&lW^b>n*NZt%UulU!lPQ6jr(bgspx9_N{_Df=4Hd9v zuF)H08y~(=;GW>W=oJeIRNB`vs=4-USA1Vi zA{e2njVxN=jF>tGWz-io=RMmb3C?OdVAPZbTo4hQ>nM z68bD_umEP9mtbr3en6~p#h#}$_RzXiuuLNdnOf6I19J$|unhdZGbAiR``tWzMbi=8 zj1s68w9!EI5rsQ;aY)4;Jn9~1Vs~iL-5vP?=@(laUBSRZEtt=YcDFbZFWDxRc`EHh zUA2@HGj=wQ*Vxkg1KeSgVKITY{eX@C^moal|Es{uz@m>kfXq_wp+uomP02GX%~I=H@SG`AEiLbTT2-A%H7DeY~x@2*J0VHr|yWXZ`o|1as-H^jK z(r0(@lM#rsoe^%k&003&Cz0cQJd6WhCM6JV9T?!K?&_po6{?`qB!1u=o zJsTL5lmv&80`}|olyC-otiXWk^1Sq$8S8QRMpYw{=Xj~}7({5b=W%?HVOHOPFP`I} z<6xE6CU>>BeGdbZ;U)z;25=vN9pVW zCt&nGa#$l}0j;--&mvQL%OqLE?q_-N9Q@T-rA9b}ZEHJ|`SRw=ggkd1lS06xeiOG! z0?|`nGTNl5&N>6f@({{3g7%e9I=M0-4ERV}=U73W zo7ud#7>ma7lZ~^pnN~naZ!w+qb*+H6=T=xWM%thPhmk_yIFVT;^HR(&GO+f|&E3%_ zep>(iuSda=ET4idDb*mEFt4*#qGM8%K0=W4(JE}x~Z^|D( zeeu`~cFl=9R`{B8ew~DU-b_Q&xAU=g-?fLhX(7{WRw99x5|DBo1Zru$Q}S%Iri+c% z58X4o?j_)01%r1kZEiURpM&u4)VOTvlZN)vlXig3?Y9sDO~V(<0hit7$F0+M;*7W9d&^_;!B(l}cC)NX?B{ zy(T+s-W!L=YT|wzW$w#L$Soy#p}ZR%t82_mFUW9`J>P3;MKoW9;1}{AzGacd8PR-UV-Bq_npW|HEH;zp<&-L2v$yT{M->Pqg z#JR*%$&6W`J#^C!KL!5w@4-tQben8!%DLh5SyHeE!*-XEzXF_&p>n>HY+k{vCjINJ z6-O$sA17VM#of2pIvU$XWP4R{m5DBxkIlF|qN=0P*yS`;9f5>0Vu8nsUms+~^nP zge?Mhg6@_t?D4|>1@9ivZ3JE)UK(yG76xvW83i9X1>|b)Z+Ge|xc%Z4VXS=>!RshX zDIWH5DFqzCd6fUxQ7@0cG9uUm_Za*D*b)FJka(QR!t74u^IJrE{gr8xKb)g`&>QzD z*S8%O_(r|gVVJEjC0K||EWy+iv;7dAA<78)m==FTErQNFte(WV8G>Ol`bV|oqP zsAyB}tE#+|YR2-$DYwBuU~gTsqr8pe2q=*NEje*$PHmsdVyB~1wH8EqEZ#2AZ(h%0 z{ro4jSZEq?`Di^qJ|Kt>^}jp>bd}>b(fh?iy}K{+-^6>$-LXU%kdV#}{dd2IzVTJf z@+9Sd=o#JQZHU9l{YpVn0Tz3KAg>uwX!l>$HZELjC0M6^vzlJ>b@qwhUIgc5q^N)^ zd|v3TY7rqJeFgpg6>UC4fJMG0&4`F=vu_-Hdlc>#&6$-idcFiC!4^V|{(CQg=;*A= z(Rn8Vo**>aGp26tYSJ>{KLC8Juq*G(mZu$a8 z{&HI&9!*S7cer?&JT{M*{6lWiFHu0PQj=sOLKr+>skiGMK6+E-{1Gw6_lpNpiBY%h z4AbmmE25J^P{z>~u_CegaU=~cBVXX9f~HCBVuiMQRWj4R(+QVwlqz)V;U)Kbzc0RUUG3 zMSv4hr><)>jasGd@tzP9j8H~zGR!buIz>|0RY|1;QBXfkA;SNoAT!Gv`gU@<58yw79yH;b$;{8%=7sq zrMWiivjdIYd_S+#Ct$Dv1(;*8^@CQ3{29Tz%^NgAoNsF{w}*9 zU_Lh}I2a?73t>d3lGfV-Cnu`n{FZ_whyn(YVf0tr>$T&D;yE~5NH+X=EmtIpgiBj! zq)AZV;b#&Vdd8g1yvgny-~DR<0GYp@?U~cNDS&yPESlQ^ezh+>^W5{neRk{O?y8W4 zX#Hz$MZzAIb4WP7W_dgRoaTqIczc0~-uz^9pH=xf~?%xj&x?WnDjq6VX0H zo*Hy5#`kX#gWyc3o#eukfVHzk=s^ z9<~s_@wcPd(@rXyn4gDJIsor_S0g&fTF`4_?OEN&?QTLmFX;9KCu z)h&_R-9y~7^>b?7nAwL@ey_RhP|!16W~~9^%k*9JVd;ay?dWc+6~{>EY3H=PNG9wd zO}=U6u?G6;eh5NOx%hBd!bHfWeK_*%8kQR(?|J9+>n=_H5$b}l?hiTp+n>N#E~l$# zjGNgpje6s~6Ys_Q_+7af%;|o2$nB;;r|+ZvF`{5h7%W(8F5q3yd-}Hmn}T2q*7K+_ zj@CK-O3FBOH)+HK%N5tFQ(AsZYV=Ai)6?~xokw56s~$Zm5~WOPpsPBFWbW_E`Bh#t zghO^FIl#=$2e%K)9&%qLKS~=Fe?p={SPPSa;)RHT&dTY z$?uEfYKuUU*4s+k7(_7fHIV1b91}O$=fjgzBr(6oW66CaVx69{S1kEzOctF!j34Y3 z_a}4T4G%G|AVM=zEFv#Vfk}i<4##D3x@xZ5ppKO=u7M7GLCo5iT>ok4`hS3%AlSbF zL&s>AAPBp%#rBK-s9)71>(Q*84_V6ZK}{{o-q1%X{|vfs-BCH+23vhC5M~x3-C%f?<-N_h7JM zQkj4KFv_DziBoaPTSa-)$*`jq#8z3E^gfaWxQ!VPaNs%AUZX;N$tUXLG3e}lUA!OB z5cSI;=vu|cKkAB9m;iJiT1%a#)aJ@a*B|E(fkbPmAO9@v{M@)yT)`dJ3eBTmZqG-k zwj!C__tE|LFrA3 z#|`Zpc;^l`y_k0N=eXFZ0CdpvQo8H;Wsi(#*q|-@R_)#mvYE;$@h-aBIoqPxtz^Zu z3Ci2CUUG^FOgL))oi>nf;M&Q^koEA^kaB-jY-*#qX}5GOI+Sg+O;{b!e-|=}e(OkC zv)|Z>m8d#>chMzJZt5ddvdD5+KnHoP1clCAiM+`-t;o#t)>)bDP$~#{%b8&t9#H8m z8w;Pr4T-p3%15?ygM$w8-S}hqz8q}+ItUz(t3fxV%RNK$vW2FHqA&^?9FD!Zzf>*g zwCoyzIZ`*i7fUB&prmA5(=S;sS5##&mie94ZFWcd8E7-kPe*rEOKge1L#Q{lMS1_S=NO8rjLCZI+~HrriYeMul(^!+ zjcYwI@2Yw8tbCx!z)%XhyC*tqG3YWkNltae>FnZa+{Fz4F!VDJ*cTg%r6I}u{Q1Gh zgOl2%4Bfw8F$=#|Qd5x1by>GIu$uGz9Ic3$H9MR6^ojx+Kp}&Xla(ho4)PiHH7J5D zO@4>65|qHC-RcyleXs{~Wp#9Z35X@9b2Z*7OVI4z?kNi!h_x+9O5T&*Zj+C)f z8M9$D*nagg8#mA}!B1$ZZ|ny)P3r|0(6FE3k%Dt4enHkzj0J2g@Y7)cs+KJ;&r69p z_$sOA$x3Ub!RGuq0!5Vtyt8seSTU;#OAjnZm^oIPpqkNVx6`GMKBI&b$jIa)&be06 zMR~LJb?3KiL6jR3)b?0mfWRzB~U2^!Y*4b#S3|XKE2*PvQ z#pq(4z-L49uJFUsJR1cAeCnLHj39xE>f8MNzuHi;%b#b7lWCmdK^7DgHV5x$`SelT zKyW=hug9fa2O#jzPr|fv$ikIq*KS&tl5}X%AFnS;cE0r%zH$7yMOkZg3{<9CBYY%y z0Q+W|eK7)>LUCJ9UgN4)fLOzWFq`F#IlfKk5NyuaHgc2`6)cx(wifzuUz(xUTk#(6 z4<_>VJ44s4MJ7$94i;0>CE+=nWmqq+edM1~XiSpETm>ou8z%rdOKDw1VeXd`qiG9d za5i9glHlm8!vcXK6Sr-AOhL0IhL=v+-P!^xt&mNNwS*yiAr+Hhwuf zN_hI9%#tdv$=%9RGa-QcOMz(MLlYKYP@MnCP~v2xGD5jhFu@1|rK37`R)N>d;JahU zx67d~bFOm{3iJS&{Pn}dcOG%@&@XPKM6_6k17qhLp|!7%?rh=|)N#>iPh;BZWlt|KmpXn)MP^PTsdUyPYE#7^r)xE=8oeH1l zYLRPIpdVemuM2J&C*tvv&UG1?p1-`$u!D1mW?y{2y_&M@f4Hl%kqNF!ks4d-=Tbh{ zF?iq1d1P|TuT1Bx=E=G0xd=xI9+0A#S!S${dLaaz#iaa-q2N2&w>B0WJ0u?nqe#y*KNgjzoVK0yBrxz*_2J~G_XC3Y9a{)0oPeKORU3lHiJQlFEvK` zfVI_Mm>5E1t-6yKkX3o19k0egQ3`b|sJSA9`5@b?xK`-Z8@SY_uJ!OAc2z28QcKe8 zOk7QhcQng|vr$CFM$^(1k$TMSc6BT4oHGj#UEBSg4+ zSz_vty1zfn8?9AtJfgn!@h}2%tpaP-2Rf!*P9-Nma^MGja?_b0f=4N*Z`kvn}V?%+{YZUKo3CWBT*m=+AP_X^?MNaA8f5H}S15Nz}XohC)>qo4UEzGf-DOsbp5+>84QDz3pbWT*j)d z@9}F6BQCOUCiktV&JHc+6>9thC!MFm))ff%-@J7-0z!au31RVy@LYUocz68b`DG)+ zdFM6P#qT#vL|WGt4b5_$Mk?SOawD6gE5q4r)b6%e#Op=BJXiV?1u!BKoE1vgu?ybH zrxn#vqs^A#NC(~Z1l&7hHnOH#pmt|nt0gE&!azK=k&xJ_iNCX_ba6^kj?s9dxTB_q z>bUtH5qc9>-#W8xRt2`acIuf&Knwb=YYq_4Ts$&4y0D$CXlCycNEk|DxQZJl>l z$IFeas4tp$uL^#sljb6=kZ`7zmz1=$mLi$s{yVn77N*HxgE+-)01u7&JQj)gdK#POf-}ocC7|8NV^*XJ_++h~10l z`iHpQ*uC9U{zC`7E7-{sN!1Mi3F+j8;n1r+%kq8W$>*n^&ZOjQn<3qTpNIvGb_iNp zi^MwJp_}&Z-b<}5J-H1extFfQ7N7BUU`?K`$)X93=S)n!`*z{deStx~?t+)e7;>{F z9S<0ubG6{t&Y1Y*RMPcL$z3jc4sOc4ta?

SeGR@7?4yV=UVj@~&&kY-QFv1&ErL}q8Q1FYysHhNyvq87N8C?w=I={-WbUd{p+ z5p;sQTfHP%9~4)3l{6mSl9_EvXQ43pv~;Z%E8)LJ+j2jS{@SKW+rZH@9qAh(_wus#j~;cAD{Q_}KK$<$}PPyC1%2*}1a{)qG= za$_Joaw zkNd97y!x|i2k#){P+f&+cjN?8COrt57mOP@~%IKKzxP6__b znND2r;=lrGX=G=ry|vs1D)cy z>Y}X|a@6&>pVL>4Z#PikBO$z2t-6?#Mr>E2z@Ha?n=C8ZCfMKfy61JCRzbn&D)#}; zrER8bZcky&IjYAi9qY&Vn0)=pxKyMiimU}1%dj>hq+>WDhX-J?@?SJ8*v!Jt}C zwaPHbZ@zrKeEXdc_{hh*xdMrLY_mI;&4P;>nGq^HRy2gi!5{ZWP<3yncta!*3uv zL}WNBj85%9^FgiDCW1;oC!CBonH-3P;^nHW@>@$#z(JaD#H zocm#xsX81=;c$KRE$e>eXk!jzSzF1>~f;|;rIr_c=Y)1u@ zs{{>l2#7%BOo>bI*o=}?$1tl;gtFn*3JqfdqH_gk>HC$&p?G_On=Ckm(TDd~*Se>D zH=G((_KmivM_Xhj3lbPtnX43yo=LD-`FqpwZ^xO}PQBV*t~~1)@@Sd|dP>XRdc4f` z)@s@xV?l^#WpDJx2)85wa?BWm~_IEUnzJ^b0_>*<3LLTI=IOHG( z5;^JBE=oT=#h3R8%NpmrwOTqx=;xD5AN!fX4os1(l4^>(IiMJ`z+kc(EXi#@I-^|+ z+d-5>a2x?OqG1lXo_9;*6pod)_-(V4s7P9F3N|{O1A2234h2!-7=Lv8Y%sC=XHuG_ zN^kmM)@*?*Qc(*0#MkG}QGT*Nsl`(C3R@}l%S+-*_HE*^fyvgphtkxO6V=+{HkOR< zJNTtC+4}c%Z`pP(E_Om9&a_9LyzzZ}N4fH0g*kjr8i~=~+M?Wj@eI>uhtdbL0{Fp@ z1tAn}J=?8Rm*U-~9U(lZwe9_q^^Qb^xnp9ycCr4*cA{*S!r>^D+&zSD7(;P@F=dEB zmWeHGB<~v{7>xzvu|7xUqnZuWZem}Yi@*Hav40F3v8t58>mtqlErPNyyCTO z3;S&I_f*Gejbd9B*?2SZ!7zy-e6-_9gI-6X_*yl=J4<&;E!P6=9c_6k(<9^fQN?w2 zWfLCWXXc5YHzozenGlePUAT4|%c}L7mf^@F&p18i>{dQ8y_$LbP{We_%-egGpl3Oj z@8FkeccZ%P@xH%;vt#{UgvHH(iKsDW@e~yuEWCc{IgW=0n(s|B36cw5X?UG@x6_}| z%`ac3;1JALd=>Kx!FuaW{KZ?@sJ#*g?o@EnFgwt$BW@wycs`NP>TrR21}*N~Iu$(Y z$`J=gbA1~ZKXrn!~YMK`SsoGd?v4BK}~9_ww+DP)ye9-NsPln zjQD~cCbujN9ZrA>ug4U2*y|53@h%x{xgr?Kh>n1qa@an1FAiSF+;}(K=2mgnh zpAZQqsuTFt3dx->y8M8A*?;c^ASPAcM?l_xp5YIAGf4UgE40V7c5$8*>1%SF#92t& z`kx=rkC>=x^DpiSvHuFhNKjN%ylQDh`Qh@%@n3!SYu-fK|F(AUpVbxquaC{vwxS}L zJMB;9my}qhjn1tGA@OZdV1 zrcf&W@01VR2xALi{SJ89PJ>XNR1xxjE+!~Pti*kZ;DEJN6GVmN6*`^wFKG$mC@fXR z{S#IsV#E0g9bWJII@0&$@C1XOh>GCtQK;|M`k&x6{XLm$y%3+QJ+3cKs=ndmlj9to zbBU*tWq2JHOIp$7HAqesSw8ps1F_`j=;-C}nLmI0cHPTWJ+n$*?rB44HeBlGoNi=9 zaa>_T^=~CBq24+P0U_uLRU1U552KgFiTfNDrw3P*oXeq)sL^zNLGEvxTpd+7yKjO{ zb-Ye4^Wh`jHuG1<7bZb|EDAzTy!$^^oq?oMiHV7V&ySaRlJS~P7OJYMCMMh7jL5&H z$qFFJ_4mf>@@5AWfNI;^evBjM;-f+(oZ!?UN0j;-P{8R&70W5Lc0#+U5iMN2BJ(9v zX#2)>j^HC3Ys~RbDv`MCEk1|NfL2N~r1W%q!S`k^RGxn-w10QFCLR$aUW>6YNj%Wb zLSK*>{1K@5aKfK9e>z_s4J##HVf%67c)Y{`yk9-_F?+u8eSRPTJl!;Q*88R)q^mCrNjQ}srYMQV0jwEf~&ch5^_lLuRqzx~_^q`LF1f-js z8e>!E;)-`h>S*b3FjK5lD97Quyz2bI3FE2&5EMu6z`{IT7Rw zyMPiro4KS@GGkE(qpe&21`h`h_{;u6UI0@}QO_{NicFw}f!QTq-M{804O66*5ZzMq zQ$~I#;YS+0Nv8c?kE}e{@C;zz16_4!lF zRB3B1o1^c^VBACd?MT$_csdcirrn|2JL{Dm-~3$9TNSDus`?B;1?^KDTS0XN^1+X` zl3w3W+GtcW2b$^ZRJ!x1Vb~9)mIMh97H)UE%(!D;mzG1ClRqnFg^O-L#g&_kAUkj6 zgv`G0A8f-Vfhl-8#74j!LG5fwvka-n)Ms#p%jKlz7&=KGyF{+;pSoek#|A zp}$K;t5(Oh*|^vZcS($NXVGa=Y}hC^Im^9KB;B1 z<^#-TEL6LTFXFONFA=92TP#IC!qyphKd3bG3DjaHr*qiJi4iW&kB(Me#Tm)T!OGB? zOx|ZIy=%ZFDt8#}3I8wLy=7Ef-L@@C1W1rT1qcqIaJS%=kivomcSvw|*CYf|xVt5| zySuv=?(Xhdyp??W+1TpEM>4#=Sep0wB{CGVuS1EfH z)ze*&>*`@uDjTjjh>DV>GSkyhNU9sMq+OLn`(aSo20!H13+sc+c9?|T!Bi|fSl`=| z4ldfvu$i@yF81q$-&EW?f6~tJW-4E}FzjV5qex+J2-ZBi<*KyQNM7a(*Rhu;JBhTI z)m-2*lU`0vqAznNot}s1=kWS-y^?G@)m|*KI_#`D*1@*m*~=^*Gm4S*Up6nR!fA{o z8@?WuChW6Hs<#EsnJ>oZ_~NcYi~_v#lN_NyQCT@z$P0dVeF|(ezX}n5vXf=vjdGoE zN#h>g!pX*a2*UhWD~Jx8t~Yvv25(iV1_#qGQ832KwMi1r>$sGMWM1(^WBFzT`$Z3V z;^W=)M@QNd%C>h`nqpQ~>;^pF3-gn#2hIkVI5eg|4Zg=^L@c(6jCH-9Xq zsjoXDzOZjw;TK|g%eomFxSazQYgd!ck}V#)JO2(LwtxB)QESpeFlE}DGhsk5MRxN1 zqsr4^6YJ5gqsFjAjw*Ejm8LFpAt#lIz1zKA$LLI^A!F^_-ti2+3$L}vdI;o$x2~Kw zuGn`}5F9x_ZuMzniWjqC@VOj$SQn#<01ka7vBk)D=@ZO~_@ynj$(nE8s3eKc0=lt4 zImy1RFWa-5S3Gs4xH&#{X%$yaU1Y{}(6C~5JClvNS1hDC?+}bZua@l5a2Z95UxGli zqtujj7%AMl#~@G-3BiJ+6>ej+dkW5=OM0=3oPJ{0x0`-+H zx}QondEN_aVZYscE5(}L=&pn10fWzDe#<-C^}=f6ALJC^GqGt2)}t+>J!;XuQGnlE zR_|pHQgxm{4#GQz9`+6UR-hG99u{q9Qwj4w+5K3-(?iK=ie`Boy)|Vo{d8e|@k@7~ z5^7K=t$)cPYlUi9xRpt5Z0u!MRtdwbiZk`+38`0-)lWooeaLNJZ4`vgsLdxPR@vF? z77^qPo=oi0>~lU9vW#KU$}KFkbmcXlVrj?T;sAbx&>QGT6|xsc)dFj;AuHC%z7T%Y zYoyfYFcl*EBvfOKt(3c|H)iLJsl7`g6oosc1j&M|(SYb!%P8Z*>hpB4IIC}jsae9o z7iPDex?{^LZw2{O!rm8^@RRTz!5d4QASr3n(+VVIOvDp$hp$;GbGS@cBbPgmCDY#w zti5?t9@~2)aWM1N_(=S`(KEICxg2QhH6l6m#GxP^C|r3teGp!0FL@OV?Y6tn(p$ zMB=(Rkbw<;*|{kdluN^e?rxzH82B^ZG)u}pW5f8D77DfO{eV_<4`t>eq1<`OE!_^b zyuoDHT}J@wo=T%+mFF`~$DJbfb_GO9mBTVV)49$0!rapNJZ&aHE1n3)WpU!@zQ z$FuuDS=@24KJDDGb02fyU;o)F2dY`9>!5q8_qp9K|)Lw}d^_7|K2CA%dX@yhBWOdJJ z*?dA3b8?MOh-6QEB#GA2ggeCjR;zk2@`Jgkqpx0{WX(D#mD_!^9UES-l)=BTyv?c7 z@-2x8Lu86-(;#FG`rXgY*7W4^o+>MrP~XjXrd`#DlD0i4@kD>ecV9AjYAC=DW6;C! zz{pI8NxQakamI&sd!lq@-6ch^^dyQl;uW)UC2qsbZUe~*WVbres7#md;MoB(@*|Z( zO|sNSPmnb0Fn{8HvX-v?m$5jBcsR~VN+8sZgprHU(a{y!mEA=~M(V&%VkR5M%HRbpA(}-*hpu78vq)Vb zL`0-JYzWQHscC7*FNmCP?CYEme|~*}EGj1EWG8`;^a4-@{S+f$)qOg2>OTq}|3K6= z@3NURule$AR+|JV3yKuvi9N=hhBwL6#k1d>v-e-^!~XTqiW}ZSqZ+NGH2y&R6k$uA zq0hJW>6e6W)7UKj+1Zb-V9$rqRilIa39MJo5zr7W|FGEJt>(W?(s+M)rT=dZV~oF; z5MD2xtoJ!M;3_GhJCGqX5cQSH$vtUpYkRNttU2g2Ct~1Ze8it_*}3W}POM`}2y@u| znP23t*!{m1FC0X8)f>qYD> zMRLRYHFuNy5gespQ>FX6wT#;iIoK*p_r2CD1cWp3Sn1~iLtzFXf=XNF9Lq~Q<>nhO z5e~B4ty@$=P9CzupvZc(#-X=%5c{=$M!giTgF-hwn+re|)`$%3yXLqrk~M3iNIcB$ zQd1_HF50)*e-#3gt_+E1to5fC#E7@ON#RoUv7tA4JUlrg5DLB!>;qm!rPX5_UH6$b zo{$W6Zrd0J&&HdDL@35Zq}b+O|7k(OZB}(wr{p0?o*;A2-BFIqDf{6H6noOmXBI55| zIvZ#_(wxa2ZBVL#&y>n-SCc%@UE3Tflqf=WuBYtXZw@RFz5 zOVQ1jv>7B-nGDUkRWW&m(l6E)jY`@#+QT|eG^mT_YMh1!*VXC;y!JtUP$=bkWP{;a z>Lk}1P>MppBh~e9p?!$#A({wTg|-5kI&{96^piCn&wM)F)-%5neh_tFGAHXhx(73K zP?!Xnnd=5i*yeo%(FO>Ms>25avMTEsp0t#p^dGpHC(JXV4j}4#-X`A#=`(6NyCP)Q zkRP?+2jRwGcIC0sV(IdL52z>b!si?y3%O~IIx}qw`Hw;Ls?sug<3_Fv5m$1ezpl@L@0+OYI~5xbx(8xikfsmnDvB?NPoFE5Um+0v+7|w=XIXdWhc?raTvCDSesh!oDG##)uX;gdaW^x*+a+9e z@0_4g$#u&iYAGs;?wStnhkdt;NjG&-3efI9IhtvL-g4?Ib^&0ioVmBLT)%aU9+viP zTb5)m9OulM`;}pa*@`H0x5Ov1-scz)p2`ylaw|*g28xf?s8d$gY97iJ&RZdCj*q|L z_t0ek+W6EBe&A{_NylrGsHl*3YK{>&pLgbRkM3VPMGUa0#^aU~(N?>#T`3;x->X+q z-c+48SZ5;O7N@AHtK8;WP~l%(MgWu_pI>LL^Sy?52voe%s$b(s zG;S9KM4Q|+=`0R~zLqpKHTT#)Am=Fk@Ih-lI0m6zUS+dmOGKpKh_uypzv-Vd%J=DWD$u6OcR%S&u&q>+G(4o6yWhjw z#eV5aA$T@7HwG6<-&{mYh>c3BEHv&(d@tM{8kvLjvab(Fgmx0^XDmeWl4o{~ZSHhaMVE(Yly`?e;yxbk<4H0)3PW&3>Hnj~e)pGu z>%R?eV?wp-Zuj@3ZL|9juELDX&Fg=Ysgf}SgoN+4P~RH0aseD?@qMeR_NVt+NN&f$j}w+gP}`mt`sU3}7&LEaQ>=V<56iEmW1kHHkQB%+%M$SyEBY z5A$<8vZ~>O==jVFA>EIMw+EB>E@PVR)>k?LfbGj02_2kzbL0A>%?98KZV#KhxLUo~=ASB&%JkL&PB<$jGV4@%T)a zBxKVYL$KQUcm*?w`CsTvfqRq_AWRv-?TaZch4GmGgVeYU!^2BV%ej>NGOS)*^ zitBEAK;0QU7<+z2VfOG*`8?@6dO{+Ld8zG+Pw6#@RZ4MpoJObM8nEd%Ui-K3W?sINJ(4b~5L^n8C`zT99t`FnGi}TeawU3mgWT zdIc8mP}GOg&$Ok3IU8u_x}OHx2nJ&Qo&TbDpwLXRc8g1m*t`BcK&_bJL5)TVvI?z}Dpw=RD%L zcs9)*E)t#S@R~#Iy#HP!z`v_*`D_|*Y1t%MuTXCoPkh5fR+B-<_lQ(n90bN z?=Kgd0BY_b9_I@QijFwvn_zY<+VsCkV&u1jiJ5lmrQH@5>Kv}UHLoW;q82V*p|n|q zF>Nm5fWayb4&T=&no?}AK(xUy%MnAYIb>yvQoFdO`9BFsMD@c={dRG}f6hoKN|H-0 z68dfp3ux!c>TzL{68Ie&cE(BzY3#P}ee}9? z?b9)>O3Rjhv9i;Ns0o^}i=550f}w%<>#eyc%9K8P@2pb2!%`l0!$Fsl>`@r4!xMWy zK&k@@6Z$3|em1V6lf>)rn|s#rIFIsDU)O=J>p-&`8wJZ99^btMuC5K6Xrla&2OlcW zssB};gBixq*Sly)(3(1lY8%lyqtV?pou9Z{o&ZgU$(&}}Crrxilga1hY1QwO z1k7q5RHi?e*87mJFl;My1#pliS!3ByogBBZQ?;Yq^Mn)QWL>j1^%s8!oma^W6x$Fh zEnrJc6%X>_l{QpYe>kDF-7jekSFlK?i=uR+oAV!{SK6a34Al^7*mKXy?srf+9_L$d zJ&jG*zRbCmt;66I@L%DvtKOQAAEYDs2{VZog;dSiqJolyyc&TQ;LQYHjg7xaL`Zw7 zIm{G609WP)uV3l*W)INd%DbabqxHo$y?##tRw`tgFL@x(TB8KZ6JQ7Ikf&A$ZkteZ zF*}#`yUMO)kK}9%RiT*Iq1Ifvtq{O9_kYoJnaJLKKR(`+q<<(($KX}iuaId7V7i-r zpDKc;Oy&2FX369)iSg}Vm{Oj@FARD6Djhve8 zoVfLV`}r&e4kPBuHasLu+vVkDi2d6>YOSkcst}1tIP7ar%c6YRuv4k zdwBL0BQSC_F1t6AwqZ+ss92Xq1X?dkLv5A92szSVYdjfaL0$MagRy*QS zO)>BhkE=k8f;tF>H0E33Fs6_!^&5%OptPOc$gvTggoFFg&X9pYL0TM@o)wR>tQn|^ zx&RK+8vha;^vVq!mv#lMigVD0!M zWk{xuT_l0@n;}PQDbGhD58+?`uQ-ecouewOR?tuI4{wzzVW77R+R$B$FfHxlgJC1RgxUCNZ zBf@pqGRdwOVdjgmRZBr^lQ>{`ey^hAP)yLsfiReJ;?-dugTQi~S+);U*TQDAPcrH5 zS%Nqux+JIMp!EJX<5iPceihplAI(rn`>l#neSKBzMV-FzjF3YZ4J2dahbKp9PX{WZfAjS$p5MNi(Hy0D;n0$2Ox^|XP%+i#PQ${ ze#5c*z{4sCFhcATt?PBl%s9%8Y}7@L8Y8(`m9FkYLNtPmPL_t)vil4aV1H7}^Lmx2 zD{Fp`oT|BdWZjB<=Uxkt$H$dl;k)!L7=++nK|>_^Rk;siEEG_}SZriuq~VQ=YuI+) z<`~imc)R%!nMcoX#I8V-4 z0tseO#jI6ZYbz2!5#8=rb^Uocy@~qfWj5^n zi4&lRj0m}{e~z09xLxd7RLwgsdR%t2w=abQ#O-f4v1=t13YCbUYPei*Lu;FJnAG~k zg#5eC01y<;9yez$M=i((YK_Oa2m&aLK*4*MkOj?dx1h?m7UheK?;mp;(qHxFe;%xE zzQ`3bYA zjq@j2{9}7V`^)|Ie`VN-{)mf!X?=aNURxWGo9kPP`9(X}U>e}MfTjNhXgoWAXM|q^ z9}f#CDi!3_;qA~NSfUszG}GMScL0>0E5Z&yw*2*sfE{53SDvg>G{Ki|Wxr1U*1@vS z{L#Vw_hIYhsF_n!QiH^c|3N0je7f~1sc-hJMFToVj9@DKWOrE{J!^6kZ2w1jU|5+#((VG*d@PwwIQN?U0Sd z*4fpqUfs{e!*@CTLt3sMcTODVkdqTi_Kfd?f|sw`BsbSo=#pl1jdKQxWd}5kRGk!Y*0vij?5$J@1IGrrx5Cy!x`W zM5*qJDpj#e1DTXa?2h@l3C()O!TYfH$%%tlpfEkN1OLl!?`gXLR(NJ@PbWj&($+~x zF_J?te{4CG!VCr$;kBv0`goni5#A+Mk7q=faGjU0FR4mhl|KbkKexXRJ+!wUhp|@L zz>?j$OA-c0OKdpvimurG^1*UW4RW(@K0t)d_MalmKVr>rTGb^{KiOk$kM?pJxaYfr z$*$#aR}XRUkaq~Tqu8{4uSB)#^zo*SvCI$)32f4Xl}sjcCmmS|Q_rg?J7O0&7bud% zUFRW|tv;2BCR+a?sqL}A>b73$$E8A;@~Ld^zW-Nx*3%HEi}OW%y<&3rW>n%Kj)e>P z!$kGC8rL+8l!nE+wr9sm)`r;Kr9+VRAO^N!(1n4fz~n9`cXP3^DPz!B?jB!J7J+gu znS433M-eO@W=8zO3A=C+3}5y+7B=F7F)=N?qMjx`iF~WQ7tLilE9!MM`y8S6k?sfP zE}wwtEwhI?NlM9A${|Ulhy043GNwnO6a(D}Ldkr!?#l;5I5)b61}AqGWe+uUm)3W= zl&yoAK4oyFne%t8hn^|KyNdz0Lb!rXZ1s^j1%h;@>Ab1{AafwFECn}75tE_fn4}~++O=ZgP=>Xs_}d+>vPI2MDM&gm%0?l4Gf`lOs>*GS0RnNL zb~Im*yYT7vtNcM#!PN`Ywi^nn_sY{_oh5ZQBFd=MZf}tXO}zcG_E+guyDaUvq8CBQsXQFh~6Ex&p4YSLbdq zIQfg++Z4gzGEB;644~)2XOZQXy?sp=n9uz^?pNK&wR?w3N+Ib}lY%AmCsnhy`GZ<6 zc?47g0r@izTvcOBWq}C|+|+8X!5v!4p$`*c6)J6d@>C&QI_E6Jp~eMQYXb z20MF+XR5)n#cKY7cFr(`Z^wCj3)!gZZ(Q4|6n4ouJ;agc&5NAIE()BJ>JbsRzL+U^ zrgKNEI2Q?b!}kwQt1J_#6kkOt(yg=`1dwbEWibND_dDAoJBvP_rfM(Edi&Hl{OZ>e zT5@C^nN(HRghcXT?!H&@oFSdGU*gQWKs>ODAc^0SW23a8Z?TLt)3 z46g-C>t;3C^Cz&I)R&6&tr+G`Hwc;2-7pI1iu)k-1BNyp8%HleF1d83SCRc^6F=D5 z1nd|N3%Qr6z|oNk7x7^M+KZtP?T*8`$mn5yTec!DZCC5p^jV#i`;lEXdsaFDeiD-j z@0X+~RHArSHVda?o9xyh>kX++i4e#^+clqzhM~pPYQnXod{ZT287C#XPe{li>9ylUsbLZ|pGj#z+;X=f!JRMXL4~MKIO$MgVh4P7nn^J<=u&#CG z%feG#l23^qnp;8=qFr!h+x>cAZ29sXVuQ{74<=#&>`B3f^SW)MO|z3Gg`H~pG4JOj zVmDe~SrC|SR`&$^!|q~r3{_j9HyT-nQ#0E0)|vVBo>yZgevif4yJY%5tjkX4>8H*n zAj@omGQ(L$$QAb&3i!y|-7Lm4lrlo?J%URo`7R!J=MCY`?MmN0urg zSh-kc@WYAnl&nqyZrys&Oz8+=W0E>?$&1=)xkL2^94pQJOHPNmB}d0aqoeAcOUKW7 z2=l1a9l#7n;c&<9NN0TK!M?q2FYC}9EZVH?S=^=wffsUIusFxS4; z{IZi0(N1Mhx^aP$V4sLfR?C|JL~0%s@QQp&d67E2DLOq*X}2_sgPE;E;vDjx0f;_R z%bA~Ffk|^FcPT(|u0)19sVX_8VqF;Bw#Wi07UWdV`w~0 zJ#bC4Wh7|ut5Sf0cC!u*}KDhtg|FF%Kl!*vtO0t46K)H*c%n zNo}w5a<4x$97|kMjw;_~X$Rcu^>7}GDqtju2ZDM$FNiWK1!>a~`RK!FI313CPra*V zSK1_ry|S<$pymhEqKKVXBjKm%d{!M~ZptU=q>8x{gKTA?aN&1_1^ds7H@+$mVZV1& zDTryGVE@RZ!YD%BSxli2V3HfGVczu16`VSeQa>PEj`vJpB-&*yv25E~B${y@$-^vf zpvpF?68{_d1lxH9I!tfYaw-v*=l4l+ask^Q%)slsWxe2%WX}+};dJPULzkk%qOt@c zKB6mm;@Q_j-#Vi^yur^xj(g7YN9A(!G4jUNA3oT>pMK|-N9#52aW+p!5Ennzbl#g! z^8&o>4vX~dV!Zyog%=E+QcW1&yvomP^Tie+17n4~BucDL_T62)(D{R)rs97iDA)20 zWWR5kyQXyWyT_W(8zVoJRTI5nex4Rl@S)?^rMeE6f|bxs7?GRREb13t(_>!r`Pkg6 zH{fV-lXz2OW8>-rs{Gd|YhgQ&Ws9nrH7y&2|G~Kbki8f~CS9~71Rdn>nG=^C{@)+1ZOhl|xL5G=yS|3k3Q`BO6n2~;46#)*sY#f*K9OM`aSWlE#+aT* z>9M?R)GOp9-h2r=49dP4^1yLG$tm3xhp%)XI(pga>J>a!oVUlfxU82;Ah@@*%6d`j zlCd1)BWY&2zM6E9)P$!Y9K!w|FeTE0hZ zgOB7WB6zo2@p!)UYRrrYdtZ01crQk|bKmRsNW%<6LItPnQwfz}O@2_RNDRIGX}2E3Y+ux=z1*pj}5r)-qtp= zp%D~GGZCMammiA1}#<1w?k6h)tt9E-M{=J5)vQn!isN6K8<|L(bSpnJ2tN>$lsaq@a!eWLA89Sf5ZgysH97sdlC`w; z5FVxh5WKw>h6DO=TQ~IyF81>PE<+8z^UMsObPq07=i4^4K1!Puu^Q7xc$oN!3`kV2 zm=r|>jTYq;aoA~}c-^#AABiGvFwChJEq7Nq?PSuB;64qaaeeR z+Iag60b%ipE{c6FsLa#0X-S|qHTMeZyeLohfTs{WiNwpx9GWMe#FrdjGg$b&8i2!B z!Pah7+I=M)P91K^oTvkSH4kuI-bMV|7@6o=B!hHSbw+^h=J`COzut~&a4N7M_&JQd zD$v9DlTrD^`5bldhY3GF;p|<2SuljsHpBrS>~Mps3Wf4BC@EYH%8!G5SwmG7L<@oZ zLrfvS1`n3*(so<1Z>@VSu-sT$h~bYxYX<_D5;R1hClUK4R$2#V4TL97$SC5l=bK0l z99RD~&n^J`3jWzLb;u>%dh%B;K}ZPJDvV^%g<-rkOtDn|^ZC^E@!h*q7U*lXicS<81gAR|ecH6ol8;1VfaZ8w_RN(ouw?+Q^8 zk&68-VO;q>0;8f5U-v2itYb3b{o)}TP;qf6ZbNw8J`VGwaXkO_O5jWE7{Q)SERdDQ zYsO9mrFE*D(o$uq0jZ!3C{{QLJ}IthgscbM!#SnG$_IH@0?`IWc~VB%mQ^+9jVixaGTzb(mjZb$t4& zoBoz$2r=caG_$xo1rJLG9Rlb8=>0F@IivGS_)3A8lxRrEQ zuvFHUmUf}LepvbT`DK$i{DnqJ3)Y4JJ4d6{v%mZo4eD-_nLrn3NfHr6YNFm}8L4ax z>m5(QU|k-wK~n8&g9J12jDze`WZi@!{^J}7L!94?`?cz^7=4FaedTai{ZdOsl6>`Xd-5T8u~}!0!TCP*dNM@Y8)eKZJzI zyxModEPCTy_!4Ae7@naXl27v<220fvbBd~IfRxTQT1L?ocEIC`L3=Ogh=+)UaLorz?40gV^pOk+3xQqq+p;*v2yVq#`O3Y|z z$e%LxU+Ox?gbZ#1{Rc!)Nf}od)5%00ty#E9aZRjF|D4gp zkU73be&mTzD4YG;E%sj-@?EET{5ik#5X(B8m~e!-`on80^w-!x=>N~*Z8GyMMoj(I zz}m-2;NMzL{ulHKSD6_9Rrp{d1lhv>O%#K0I1s#eAJsnkBT5w3LTr|988`uQfaagT zG;sWXrpNysb^d=i=OCGlitw!3{n}P*UPMU=kVU>_?kY7`9Q$%~bOfjxto42;EpJ61 zZIR^WPK;}zIp830A^uivzyo#vAq+``xge1~|C!bN@0FeQ&$lb;76%t7e*6#C`hUtC z{*fER)ms@6$MdePB_K2Fpp(~rd>D-Pr;b?AI>&Xt1lkk;D#l3Cs@%ISqjeR}Cd_5Q zqq#r>v0V*jBGz5%OVoul`doh@xrf@DwdB>A;qBwgF8I-hM+i$|&#IhubBHwTRO++R z1)3b-#)U2>9^0e!!kb#tv&u3rdFK=z>s2~JB!gCYHCNq!iz)v_hOoJ(UO=mTcIWAI zl;elRP+6Y;n`-8k-!n^@Y8~(bc(T(Q?b|#apSzXT>t&ILid{dnTw{^?4FwAD1A z&*QHjv7_F{_c( zd0xFIGJC2*|Ae!j#Bn}Iw@iB`a+w@lk*UAFQMT%JyT|<%O2YtXTM?RHYn zudc3*3BKPJ#>`r;&P(=cYUOZKojd3J>_Qe#4VD+U=U!MEGgc+)hbtr%I^ zrxqMiZ+9gNuG`Xk_Ll3t)OOGJNY?1qlP1^E`%Na(y>*pvwl0a_-?eEoe;r2axrizo zo7AN|p7@8I+{er39XUfxPWQ8=@%v2vH9dsflHsnYsSmnoe^abVZr z^&-A>DXm(LQ>HTe)T43WVSoM$N3r7yl@@j`i+dbC_^&$D>TMb{aEch|?!v*l#bHYuMeFT70OrW*)YYyt^iK5a0n zo|E^ailXTmofUXd*MA~3f6JzJ$Y$@remG9YJBlQ$c-rG?$`nlEw!iEkxb!-cALczL zIhi!eApyGOG@=|pM}$6%Jsy+KEOUm<@M1vTa-$k!fj-PS$A|) z*YBT;)@G3~#^~pO+p1KW5JQoBKD@5eCf|6xlFsukvb_7F3excT4V6Be*JvfFn`W4B zIbC+BYkE}vicQ5--F|o7eD7>?;}#_#X2U#60*W6eD8vk?I3z#RR^&UoEM$e`yHi%) zYoYuz+pv753Vum7*pt(YZ}cSN}V9Jf+R{|FM!VW&QsrNv=+eyE&XR5hX=kDGWd*feRaxe?=(B-X zr2=y8rN_l5-vqsphH-#L?UTBj^iB?A9uu-)(?F#LNF^SNa2A0y^9w?kB;+qpdAGozribRj%1)@dt;6h1&$zI%s1(m|;DOO1{r0hFH*G zV`)pg14+g^M+FZad~S6}S^ZU?Tm5+9Wr3H@?s$eHU=ar<=H1*BdM`!feuAk7b(&?G@HZb&kgdO&g`aSckMA_i&x}`9wxZ}rkCTdJ__2V%ir82LC=h^N_eC=-0+k)D zzbT!@a`=`%omfS#r{~G&{Yv)su}KN>-b!8Qr6B8`(}WXz)2Cf#Y8qXf&u~uV!nh-W zJW}+0r)CGHl6KH|S_#+$AL6(W+G!yzNcclZo|jwcNwcU zJ(Bx0|7!pGRMvX5UC0m1dC%&d6@GzzlD(GIfulmY>48_5R7=fkT|8ZTEo!Vjwy{(l zwACS#T0gP|TK#RbwyTh|!U&o8-0XH+hWEUz`90}aB@A(RSaEn0`nuBjexDVUD$s%( zEO`(_f5bj!%ntrmlgBJIdo%j9(dF~_MAsxIe~pC%-QuzDyU%S@)MyF$iO#>w8@pzy z8EiMAOEA#)YF+&R7v#x6sUU_?&=zdt`0{e!uT;TlvF@0&7R+(O|8R5+`aCu@B_5w^ zTH6~dMfE`8zmN@lg9mJWJ+P<|9+uuHhZc3G{wV%~qMr=!HrjUjZv3nItXJ>Rw!g~N zt|MEWShakFwTtGdd4Wg2-BL(f#Y;R-#26N*9*t{GeBT#`o+&NQngfC4N?7sorN&D^ z^ie#|CKCp;Ft^*EW<5P?HZEdUx9ebZ#q1}qUAU`OI#gE@8C!@9d?m#MKYGdCuq=y| zZU#S|@tR=3Ymb-rF&)KE`YZ+Ch|j{3(l?EVvT{U`Y)Qg;usMdGtqN65Dr$V%09&Wu zDQ&@Ff^e))QlAPtby5!&*f*+5^=iasicj>$M4zc;Eg5_ND!Rd0)^z6 z)}({N(!=>u9QDI2lv0#BX+%nY6fbCmBcgL&cgMLD`Da&i{%iY6RAgDE`41tD9G)Yx zyE<+wXK_rH2c(CMjVIDsm+@Uf%~_;M=tOQs;$zvI@Y7Ov$wk4y+}t4l2Zn%|>}=Dt ziK%Q;xB^dYrm1O!C?mI?FtBsPXCnG0H8&R^Qwe?}?YXDFO)>czCW{da`HFKVB(3rE zVqGWX8|j{d67?vayaY@fh9^x9{Gr3&?2D0)-eEksm2+sIy?v@-;r}e%>}%X>s^MCa z*zRAM#82TVZ<5Y7wAit)Oa_FOkd!Cyo(GR;FqoKD*TlZHXo(Q}N-6!BhWk76g;YVM zX$k#kT;_npT^0lVsZYp*ePOkg{@Kh+#d4O~KlKExualbsB`2feF}&zDJZ@^J~&;MJS-n@^LQx3;19LUvY47@pNPKq zhCQCCAc>BKC;d9=aS4f#(zKe!#X~G-4z2FJ5gnrQMWBY5H!=X-!mvU)`r06k( zH5yWaDMR!+Hxu+;w*`}vQ`y<^A8K>2kx`T;&zrY#l@ydZyu!S5bK%S6EX~<)Rse!a zk8?0yrR~(_?zRM`abbv?T9-dB%rkYhZXdm`Q>YMqFpU>Nzm74BZ_3D|N^s0V_WY)C zrSfq+vqg6Njgkt-$>Cg6av?`i0kY#SL=?FM&pV4P(@zNKM2{GU$vIfQSDJH7NlGga zDk)jW6&9Md)IP40_eMwZb(S8#k1;aN%{6QJz|kObcpsvqWQH~|k-ZZXKeMGXnyNE^ z(d{LdztMCZ6@0+C_QKK zE=^mBknfO`^sVvh%;ter?&v|!kCcO2_lGXDn#lA%Lh|9)q(%Ps(5b2K1yT4ev`J3K zdy_vF`I9PhMvqn`>``JQ-@4M}*)yZkHrekhn_p zJ5R&#H(i(!;WDnvxV}zsB~L1JE-5b)a^kajMpeA^!dmiK@M^+0*YzA z9noEpPR5lLpS(6~(RXNyX zFe+L9|_z33VxBA|IX+=aePxk5IXgY4S5{T;on@wq!LkyYDc zji)-uudW2w&QUGgqJy>)9alb0^=xwD%0CYoj{$Z^!BOPx!jyTO}r z`K#8C2FDhG?+sF41@drX1(*;Ke=cxX!lO;O5S@e&?y3Ws*b zaa_^;h=T?vyOzCW0ToE!UQKQ`*t~ZA z>?AW2d-@VeuL+7(R=|C#n+Sa#$T}F8Z!HRuG7Q}Q;ohL~1AG4{$0+hBj9^C`b?{72 zuUi}`GlWBlI&MlEbP&;s4+{ZWX^=1mnSdlD-;qs!*!}f+ha0C*z+oNR zTOkhqavPocT@rO#@QY=2@n_Gmc0^rA)0#u|2hTTK)mXAwbzQZGv(R&xUBqKhCR41h z5SEuhz6f7@fP4e9toZZBbFGcT#Hi@o<0fL#8*9!|;?!%%EaEX(Mg3b5zdnDwnuxcW zPUES@0)o|TBB*mIDl8?&>hbc)D2bVPCe=|H*8oo|3QyUQZMt5UkYW5WTc>Th_^{jP zI%^RUEXjnIG%gi`u4y({rbD_N2D~D4yFtT0V8Z_yI-Fd1#ZvMzit*{rh_u5QomDJ4AJK3NhPxeeX&CW`zEWyD>d7z9no#zc@6eII z;xM?7QEnzQ+u4CCeTV0`#wAGq@l12=Ye6&ekL~_e}Yf@wFPUZT%ReA64~cWOeF$SMK~?cbx#3V zhbG-xKC(G2TTD)Ez9Jq6+XtHx^YvNY;|wx~FW*ze&>WyfpDt$@{qsq;$IrJOKQTGW z{<*J0xRDpkA~$IdQ}%rx_;SQ8G%~d4?LLilLo{b~ve+2Xww}hAe{?ENrT)ES2;L6H zYwiy|yF%|6L0jI+kI(TlWzUv1U;BWblWqGU-#E~m1vvl2qQz6e?yPCX`AYvSc|O)v zwvfdGT1Iz;jc+50q8A8}!xS0OXN!}fDw9xj^B!J(f(y9Wo{>39K(qWyPeMfwGD=5n z{SU*(Tw5iE7yMHy?By#SRAjRx`NTluif%NVJZ@K~?C?qlo8G`HtAaSEqYh{|Md12P*KJ0{x6D%fJm1}w;(AkU<@fC-Hmj2r^pQ5 z4Woc`NOy@e3=L9)oqp5GPU=cgL}|5$6Z?D64TzD6n_WNyR6;igQH6`9bTBWNgK#KUlzplpV0GkU_@>|TLHlMnC*0dF zpGVN@_1gk{PlZD3ynxMCiLfiktR9Ia?`*x7AJqY#vj6M=Ht9vB}{&P4f(- zp?+l8yQ*xsUI7y9#al5*K4dMOPc@$9d{2|ER{P>ku2m;Xnh;C%CB&dKja02k%uf22 zy^NMggbh|#-#D!rqww94P6W41f=6%XZH{dz`G@-?Zf6~F@$Wj}JU`w(fiPfC#hcS% z(SjTvvvCtrq-%{3iQp`Z5LePJiMS}=cL@=(smJ%&RChoIP0H5U;=x?kLNZmXtsunP zs|*gmW3HKXbaENhj3M!FT6Yt4C!W76aCr6zC(`q!_MR-mDoC7kZ-v)E_U8i_6$K?u zH#?DcK;2Mk3z}yR8hxE_8p7kDNW>?j_<`b=d8inD1RpUNFIw)mqJCo>-p?~>n`Te! z5SKzIM+hG)?;9G29}psSihCMqPsu5*Y}M-}?4&^THF;+{#Y9>w0(>6V4GYiC*kItM z7U#H@;7G>z9%nUqm9}MYiZ3Yu=7F~Xo^M1c%xj5=)MkyP5pd~~UCDF`Jnm)V-%oIy zEAi-j(RkP)>cvce+Jk*Rgs_`?_j$!FFG+~8IB6ES94Zafsf`UfDJgLV3Y`tXCeqG2 zZb=rWPFO9ZiDvBeMeIz`fkDf2iwZ60NCZ7&DauzL^6g!;CaH81eFM@PqIBeA=YJ*% z!3&jBJV)Iwtmc*s&*R-MDbr+k{jDyX@T0lqZABM2om`&OjNjbAB>RDx;)E2v8zU=t z*G1V-CKk;1h93C&0B)iuBsmp4Z~h@~XbCNAmIuTGdy#x^sLx83NwyTJt)KG}&vbvY zB?7DeCL9uRAr_hP%vVu2#Z4%8mO^B^gb(u*Sw$yxK`bgS$#hwzP2ZZ3N0+k)W}rm1 z)1=m;Nj*x)Zu<*2pBAzb_g1kd<_%ybF<230UA-H9_MtOL|<1 ztGPvr)n}0^*O1#yKPqZ(2&LyO?AZEAImNwe9J)}S&q~<$z@sLn0T$zXey?yPs%}n0 zqVa=?n_5pX8EAa_9CgqicIO^j|ZFb+Tn!lQWbMqCPS3PxH?7y7^q*CmlGV>+KXfs6C{OT@Td zm{63=d-naG1l{+|f7X2^H#i@T%-fFG?c%T8b!`HH7EBFq(?@4iRB~c#9?}ya`-C=bmvWI?Z#p;1bAiRf z{rOx{vacnpTExSgaX)EWD)#a4d(E@&ji1F(3T3_V zTKH;VI8&d`pJxm7)qg7K@B*n?B8bJXHp{Uv_abx{76Otvr2Q`{R>E zusXj!SE35BQs@AdwCR1}r1F8tACx1PxNO!qBp7=`3LP0KG?GMet2b4 zTW7OT=dt8b@`e<~gz49GOnndDOm9)Uk?Vb&QFvJpm}wOY+S1c`P!r1?d@H3&)^qOVWkUpD9;<^mT94K?(PxC z|NR4~WK$|RSkD%0?uksRlvJ~0Sunnxx05FL7Jwc~(wrEpD(-+Z@3Xcx z&ofWmg)DKV3C7@Apu?SD`~_Fgafg*w>$|EX+!abmGtY!>$ya(+EdoA3;YT&5y=o2@ z-%<6&C=#_Vuzbyu$GQ?pb&Q-Vh1uP+_qVU7&SCpOEo7(EqjC!|B7n#U(UOjOWr@a9EqjP z@Iov@6HHj=N^!#@V?-mewg?2%w(;Q`eZ~fStliM() z?6){YC%Uu9liJ1eN*-5F72ypl18c??7w5yg5wGk8+NaI~oly>ltfQkrY*R8C?Ykve z{obr8Hx6=PdUA$WlSOQ|A2-k2Y3+(O_7e_al#7j+s@*5r(ULC%GaY8KLnChmXN{Ov zGQ=6mFp>TzL6f)v|z*zH;l_ThmCpb2OFMY-b1pZI;1}Ru!mFu*nN6iOe?6PLSHg z8b|iUwXN}%Ky4Z~YEl1!C3!T+d{YHvArw{T_a3sIKovxj<%qVKSuC@4O6R>>C*y?E zq^%l?J8i1q>rbI`HN)N0>6Vb4QngkzL%Gn zAsfrQ>7C(eU*OFrMEaYb{=7MuYCvBOudewtON~86Z)t--6ClBV5!GlBkSb{07>(#l zUg>amG;&Su+!)1{C3lBr+^wv4l2$!4x;UU?tt^GKBMO}I0)&0lD~w-i%vct*ab@c!uXtXDN#X#qIFzt3aYYYR z_ubQO(h;% zDxy&|Dq}8|0o7tQlL#i2NyDU9fiuG9<<;lxdBxPOjyq_zRRT@-cdv*h$biD4%btgKphb0yf9 zrNxT47t1jBOpM3A+fNzudK?LQ#ZHa0V;=xoyM52Yq~{&fUSpBJ zmASNpRT&dR3u^=8n=HrM=U_UQAo%Bv>1m^#4Bddl1;IIzhfI8Zi&eb_e3sY6-x}j` z=l8zR^t2Ka&6E!)QMH!lg7bhZLFGujPY2rMX6ddGiFnFLGSgH%x7=TkGRobU4y;)C z_lUtH^x#0))yco*OD=dVf@kr<@;Q-DM{bHZVtAuOx~C-*47Lrd{B>w_W`g+G-3K~b z-YYB~F%4VT3apuM^WzSvHnqEGg}XEiaNZ+y0drJ*p3jY+7QAd$URY#x#)IHASHYfU zQU*pya9YCVb5FN_ic;ZN<{e9$7d1n`%*Wx5ELf~|U`s2wIM2$$?<8mz(CveO#O&>C z)BH~5WFB(S3oyBT&OQ?XOn5NWV~Tt4+HUn?tvZL_2`zNC=H({lfyEyCdrhULfx(J= zIb!2*X(riDr<^(qStAwk37F_zQUizcTzqbkZ_uStYplP(U4xiZ0%Sjpc!ubTv%?dA z_yB#iqUdi}hv)mXtcwg}(kHD%W1b+1@U z`f^H2$mWmsTw5L@pCB%;MRn|RC0-IDAJ0RHa`-+t=BsU^?BXxOW)((8Vj9C|8*qIN ztaCLajz1lY2p ztw}?q8sTl_S|cDjKQGaNsp>PRHS{cFZb})XQF|gq#^43lKdMbtEhWZN~PYG2chLglJ50}W&Zw-w%D`z{9J{Nx~7qz%= zJ5!7=o(dJ)2}lNDynCZ)b30O^duyg3sjKS#nsHvg+VT7o&IxW@BFn7lxkhpebE3pI zC^y&f!oa$y;xvn3{iwvPmVs1Z@!&d}f zsWr`wUap@+MH&zj$!n6O7)3E1)ZHF9ciVM08sn9$2-zJ^R<0WjDba``k6`bi0VPcx ztE@TPNP`Q*Ko@*fkHJZbRJ1=1a;2&Y-r%*llp?+dvBib!wp6&CcL_Xpug?T0_N&m% z`5=E5-5DuO`&~LCW~F(!IWLHPyej>L_5fIAtM>9sA05Yw8(}Mlc1dB1+N{;JkaX93 zyhX2gT`X$$!iwnHtbP`2H>4kWZzV)~S{!)Hy`!46BB6VR9)QkG{br-3N^f$yI;leV zsegWzu>Ok=Rc#tXc>#3X?A{R?1iBPRtS2Boi=YLpmi{mhNQzp?7BQ^Lp4;l!do781 zy7OEda}dA1CPDxK9xY!OpBAX8n@YIqI z`!eaaF_KVScAx~2AIxa;8gF%qX&yb9%U)_0hk*G%Tlru78ePs;Ki2YidI2vOseQV* z=YQEQZ>ZhD>yxsWd_BFA3O~rnNFoNhiOyzcuDY#I<)-m=2pLl5NH|RQ+Bbxd@~76osS6m47@%l+^+$Ook4Hj9r9+8?Zi*f@KVFIVDbKV z%{agzph(-=JG~eDL1}U)%=UkVP|hmd3yz9nkF4P;b0fs~q6^9M-ilGi1B}O}rk}kY z_BU0^7$Qvnus{J@Q|BMed>`Zf_qHWq`~K(pevI(4Ugm$-0{qJl{7*C03#b9gWM1qu zQPil(NM?uy_RQ5T2$*- zKz-jigp+4w<}&U9U^D+^D*PAlDvZ~hTY+c0ak6d57PVqh${kCL z*8<(2EHJDCb|qm~X3h0(u=rnkoD=BXF{82@d!IA1+J~>arhH^m&HH0ZlGsGG+u(YA z>3QstdAxmKAQv#qZ?4!rKxPIqXbpFRsZ?vfSu%s z`b(X7F;Z{)qxKHwi`djhB0A%82@G`Wzxv+RB~J3|qMeSuWs78Bt_{0f19a#1Wad^<*uYgd_Vtm9%Qp(>JH2dEt*>@`O4AVT2{7;gs6<^=-a_ z=tD>-a=wZAH$$4i_loWgX&&6oB&vK34K($kF#H zg0kh>^{t8T^F9Se@$391t!QdVPkEm(!R**A6}klB+V9N|V|4yYLtm)yqW|y$#XMLw zkO&-eI8G>iWJofhNGcF#aITF1A&SW6Tv*9=`_{d3jm452n1GFb)C_?-8~*B;e)vi9 zQ=sZE!Y6dGUE4 zyxV@0(z@KQ{!GH*JE@k0%VFRrW{`TjtBE1=_Zy8NNpo?FyePX#jL%-<{-tI)whguP zq~P{FDrrLju$uYKsL!= zWLj&I6tWkvZ(N{m19ekV#Grrwi(KKcn=2mdJG_`fDIeovQr3jQ1CIEjd-6n;=8sm zL|~+^p*QE8e|}N^^d@ME#sU2E=f;)z!)PU^4S+Y#L!#Z(S=o_GNg}PfVNg*z@ahj4 ztrnQbDqCRf`jV_r>$M1t$U{9^X^n^eIf*4RN74= zWFBMt?mW1!KbaO1V7$x}W!dwJ9lQ!Z*njh+q|PKu_@mtCFz~JcR+RR}Vfm{i6Ht2d<5%Gv2N240G)BfBZP3~j6QQ>-H20Yq+i zc&w4=j-+xRCtGETdtJ$^5JwIf#c?}6gQ^|gzVzNt->ka60KlCuILyoG8Q&YeJ0E`O zJ{UE=Qls0oV>%}1POm=x6O1s*SgRwU8HEDHUh~KiMYFdDWwoDKh3t}4LiuD2UM?%} zo7}bjPY)%DoC$M$7uLj29cS0N^Tr=oL?1nPx!c6=ec@m}9aE^}V&~#{&Q*Pigj%vB z8nyW$cpbPo9d+j*X9`Es6JiL3<1_s5zPvH!5G=wBIZfgZb!_8ua z($hSp>ncq`ZgTW*ZrjJkXD};(3PFsQIDVFQ(>;si0a|8bBMN*Z3D$Ad>YK1b34Wgo z^1$z3+bXuto;}G@UEALO5;<8T#(11swP;XiWT!m3OQC)|7@3DjJP42UsAW@3*Y-aB zDQj=0N1M zWW0H_pMn!jSrSyLt^Rugi z@Bu9^PzH%%{j>qDD?F{K;SL+xEi_skXpw3hhIK|$C0vYz9@F;pZJt+?w%_h!-{Hf1 zCxSqZh{TMz*rx)feb=71wQCRy$UydS*UyB(N`llw2A3xa;A4E9H% zCbm;wV=^o{jrD_+c(jXiSPb;3wBF+wKsgkBrJ@=z9KN{8+$GsDxk7Aox0q`^bb4_6 zaj*z(aasI1L?=`0S?awRc|(9R2DT+upjEc~D6hBt#Wy-{c8}$oLhoCQOlh46PK++B zsy2TLBGH^>YJ;HdafV9nF@?4cc+npdxzz;`LM4-j)q#C?K`C?AZsi}Gu<9nMLNK_D58K^Kv+2(PU zSn6t73d-})gAln7?>Q7dxW%BcT_!`7tb3UWUF(iNxBM<|5r0F^SjozJ)j!FvTZbpXP_4+Rt3usVFqR5y8Awp=`y_`~k zD3S{42vXwd^w_#@9~n_waz@yHNl~VBkxsp3%T|yz(E~qPDe--p+a(`|8Taj$WxL4O zGTW`*;XY0X_$Qc1drXKOFb~qZrUjS*?EnE&-#b|JBNLIoY*P@3(Hs|%$DSyerA;c0 zo3T{pNG_K6j>r5#6kWj>o|dqhj@*Vxh}44#i^8PQ2GMGY+%oYw-E}~(~OaNBdYm0Y%+QDCib|LRKT5t0pN`ld2u4&G7pEK30W*OB4^|B>87dF zIcZw{jDL;$fWsO5=XV{J=MRZcf{EWxVa+XY$6th0_5ExX6y2W%qdB5+c8_m=QbDds z85qqN3$KDF{u-3l)41WqO7RS8R#cz3{E6jod|5>Xo5SCstbVA;>L`jflSDvX>iBF< zFJe(jF*NP#t($ANhsvGW-n=#iR#kRL2!goibF+Ot{Ov_xrC3zxJL{RrcbI(dq|903$ zsA(U@F@_)QRDBLK^}+*I*IG}z6E?@e4?4I=%h@~w%>0F~z%HpuuG5}J#ub%zK@%K- z!l;)NBc~x+g{3AtiJ_(AQcTN?z_z*nB?g!BEnbD9`wz0^Vx^Z@cg)%ai$Gn{C?+44 z9)7J{zuhfO)!hmM2-Gd*by)e$Q|C86#_#)|((hNu`0t)oJB>ZN-NX^DJ4`@5C}hFJ zFbMwM2KXKT&;Ro{P_p)4I}ZP+mSr$5V+7a!5KUtq1gziNKxtJGgi>ozUpoCG)}%o| zS}o z*x;bV`gr3$hP6%l4(4a3;TjiyTp|}(9E~{nQOgsIF6l>nSdaVn&YrU67mFZ_s)Q#i zfcCiC^o7CI&!hsE(mn8WQ{(8QXmSd9g8Z^dGeOMa<=T+uBLnU9;rURRalddC1IgDZ^I|rK(W!EB5FXr9*hEQ|HaK zHHq*}0jx0x1H&2i-HeQ~*M}AzNA7er>v>z?$91R#SJRl{X$|wd4OzQDaHDJTW}kVT zAsbmA2vuF3CyCPes|JGiB^+B$O3CfnFfg*k|7Mg(rA6cXX6#YNsHVV9rItG5&Y#CG zjM1gWx0`FCcaiAZVK<6fkDDR0Ds1|GYF~-oiWVf(92C)rGTIWtXi~CjsEx1fzjsNt1m8l}{YE-#3l13o<{h zpQM&(4}$1TQQF;@{hl6TcIiMB&pL1+gDyt=ef*ZSGf^)dAkvV?* zr2leEXMLK(?Hu|^CXw`nZGEm!>!WJDiad6i7BUPB3L@YJPTk(NHkY}l zN}8Xa&$Nt-5gdgF9DVGlrNF`^L0|f)WfYLr!r&GIbC`(au{Ss@&Wgx~^UXFm=$p@a z0}Jfa;~zg{8LpYzlW6p?$qHeQ6w1N}3MuIsf%|7XmQKn@TbrUpwo$KwGVVSG;TvE( z?loL<|Cky#!epfXPG7;mB3%HvDCP`fkv|5WOP$85$*e&u{6IFE7mzz84cZG)TUk5? z5|oesiOvuR0G^}&F!=uu|4~eE#0cI3pw<8iH2I_GB>ugoE=V3F#awj&>O8^mQCG&- z%zwjJ@>}RiI{!BLN5=O9Ftk4iL{801{9}cbG@!`uzV!=WHgMSg6|^M8a`iuii!i=u z0+Ja^hW{&Egz>uUzt5tk|K)!m?(G#)Carng9lL#;`N{6$cyc}1GU3rX5$N)l8&00#{=PSC0ZT@8vlP`eo0&ZO*hy+K> zPwH)*H;63P3zu-kH23%NwufcU@47cx`0HEStV(P1H|xC4Q>##a@_9UywSD|+<`3j{ zGbGG5a+Ilw5*ppjA;`XH=97{`t-?K#y&yXbnP6qQ{G-;O%dO6X zbzCAM-33Ee`F_eQy^~|Q+pAV5&e)s-SZ++Mv93{u`&fZ6f=BNLUXCMb_*^oB&n-U! zJfL4=DQ_Bn=!43E>|aG=@Ys;Qbfz0r=w#&sCnf~!pB}r9oI3(}bBqcC zv4F>gg?VkeO@ZCUXG(#Z+ZC>(xrUvr1xCK5kwXWQt~(2|2gocS^;;u~it}wTG?UiP zYx$88Hv(6c{YHQwd&RpKZg0GbI?*Oi)$|>lZI+!4Fh4wWsL~N~Jhn}XL%(Vhv74mU z{Whk^P5tsN+@6|fB9}}>LR-=som}n-Kb|>(>uamI=+yjV{szw!*5jOcKy!saVK2rh z&}G0ScgYY|jYvb|js0A>g}F@}`DWU$$-9@+{D<>O`Vv%_r`xKHR=1(yoQ-LI0|)!! z8(-VF^vI6g;ZHwE9?b_?wV!l#b4(3kw_UB#V-pd{vz*Z7cwE9oNovb0fAmI4E@*9) z)c$JyhKL;*R(SR9+e7*v1-ms)o^;6tpXZ`1_4mo<45*^Kcnv54^84m^)xo+RPc{d- zW#ufI1RQ(Uc5A#TxbP7DP7}4q)4op{rXgl{@MY{|?ru)>@;UMnkk`DvPo&y6rOug~ zS!4AlltWkOxZKa>ph1YqI2%f)LbhkAlvVq^fB$Hw$B3r=qB~CPZf6-goB5DKVh8i4 zZWpOpFMMZ%wp+H(J+61^lyv$yRE}y5cbNQ}-gN_m7!?$AJDGIrgwCaLRymdob_d!z z^+QFOY&N$>EAM%+5EA)#ab}NMo8pBeV^uo|=jP~N7yuX&%o_QgW|C)KG`T3JA~tjy;r%@5xrz~J4EF`h)nCb#%0$A;dbCjmD{ ze(DZvD{mS{PJh+p5fcz47-(rQezPc8*af;pUh^Mz4BM~~BVpohle>|)UP3lK_c9HZ zM&n{yu70X;Z4G0ik4!B{o1DL7n(0I$(p8(+Uc&-h=SJ(OfQ+UX=?~h>ELAz()54K|==Ay!Lg|U&{r0D~L%sIKut1JWgFo9$eZnkUms^#h%K`)lL{rzF z+f%c%TEt}K96W!q&=Jtz5T2y%M0qrn9CBpkci5MpAWYH1GL~3<@j|sKo@E6-D*pLf zfepo&Esy-|wFufkZEe(*G{xI5My{5KMldNo9<^Kk6*4;I9BfIFsS&UGJM>e!85a$= z2dFhr5(XLAkVB8Aq!A{tFtAjhvuLE~fL`|8kVE&<>&QB*+dM(FJK2Zgm|+NeZDuj9 zUikN1y7_vPtg#x5z9tWZzNLe-evyt$7-5jV-$k;|ppe^n zJ7yKog&XflUm>Mp1hZ9}P@F6{vf&X3RJgs(?ok)*LNV< zws$Cnks$@)G<)t-ovOiI2X4K3f7z5dea+*EHtDSMYc5k2womEl^ZiEWSCNS=$LDRXK&ODRV-}1e5&>;UoHE%mK&l9JVYB#hCQHxH$l+y8&n@n>t2gRjK96N ztzSs7gZ>WcMw4?c(7gEwz5nD}ka62aemxdhLl&+7@7>Ix+fJ2}cI}n(MP=;B*>be^ z$=AyHfGU+bP5-0SH5DPpc_W{l7dHKO$OHaAeRfsyI~r$WaVy46&hwFiiw1nU1aVKt zXBI%ZjV;%NM7@1|x%)B~6o)TL6seA0w=iv7Mdd8|9ZPXk-*|Q@37^*8)*XcP9`fAf zX|(umlLT#3*by&Ah7_&8YVld`C|GnnaAe6#7s?%llG3)C>KLuS7>N?L<(h^1&c-(J zD7euU$hq84;(f1N-~ZT2o4~a0RYp1CcjC+AM+IHYYkF)CxHweb#OCm{whgmT zoRaF6N8^n1|8c_CBZ^kyV@;}1pPb22=4%0b@84^|)&FnRctF-N`YoxnZt6^U(ccv3 zn$2wOLteuFO7y4mxA_kx>Hn{yzt?V;e@x?wWD*PHgdhCAZzYt4Jb%K+$M^pnnQ#BiQ0DK~t0Z1|4%Vs3iL%)huLf}!qK*LKuup?-`JSWH->BY9}a`8v~<1AR3U^GKnyVw@P*&V zmYv_+l%d8G))N`i^B0ZQn)Z)}*B1rHIGI{!J0;EK2Un9L5Gq3rIXyIuoqYap7UfT5 zzP(fr$tyYa|2+$cp;iXffYDYYXh?>CgX@iGoC8rt0TMApmG zD4*=gg*)A>B9W43p)*@xuk$vS@o{~1*!b9yIy&>vKFl~HAhN;6 zEzOymoqfn3*=q(|Qhf7h@Za!?cDsCH!NS5qZAppTwuC@ky9^pDqQ0rc z@3JwR<^#|*?)`BQHvp~+it&#sN>;VH zT1{lCSiRDHbsCPqX35#oS*2`%n31N9En7lT+YXcg`S`O}O`Y zW?-jOT@{;{nBFKm=qF`VWF#&#W&MRLsmdMkdvPK1dgN5U0nmTCPDQ3wrm@N}^d#Gp zG0o}uY2lgD^F_678107j>H6YY4ov3ofga9#`z03g;fykl)FzmchTa2kdXz}vzyZ(~ zV|$K=W<~w9H+MYi7TfFA{kytC{}jzW*SScmA#-HCc#GK7Bypis>vsDXs?EPB4E$;( z;2NHI#=^}r|BPdvNoFO-_RbOA11MXVuZO{j$!)qxzS>;SMvJKn?l^ll_u(clMI)J4 zs%M`O{f_9-Jpor@APlot7(J-shdgUNRa`TY@>s&YOW7_$d7k?CcPBA?3@*h#tZ-(L z;9U>Dx4^*j)y_<>s%eLWyuFcoy$mbe9cU9jL`Odm`0HCX zlp7n3q!Qq*osg?M-m?}t9=jlDpi7bDZx5K}gpD~awss86x8neM2>*_*i%|MUZ+|h@ zKcondG1sn8WZ^*uxd=cb+;iXu696K(76l0*@?Sh z+F3mv8p9hp_u-Oo&Gqg&nn9H*oVO4(%D3NTRO=ow9dlliyHP$%v(S3ifj zjo1Kx=f=+yn%9nQA<06%I$kTEf;J9mUe<96*4*n>hOW&AiCo1Ib5lA9um#kZssWz< zI3qV4XK<*yH0(NrM~v?_RlmlmnWa_5Y@mA7%h|Zz^@(5f@YAKah9c|MEO4n%+pX4{ zMR8+;w*8)7bvOI)T4Q5G;j}6qtHu1H#j6gBi2{4K)x7z=J$`6b?b^CD&sHDlXij6d z+g88x<7|HPmS@9=3mtbLe|gytb+x+wR7nxe8*?A~+20*P+7q7IH-a41WSde7AQ0Kd zk9;@|K}(wea!;^gxGpl7bLz4ck4vJX@CyWGxnFzIcNvSiLj{0Y0;# zUdM?;sh!6%@^P|OZK6)IB17mawvhF_E^R53|=~aT1P)e(~kYLO1q~=7gkMY%nUida2a`w+Z{n$05E( zR529%fc!f2vb{eRIwjfaj(i)j|fu^%<8w@1)@%vhx-k5S$K8NN`OHB z)YRHHBzd@Cg+hK=v@7i{na@B-;-pm{pWY)%5X(ECHn_U_2%+j!qIb2h5HZBU@&g0G z3%$B2&z=fQ;&Ko&yBSmMZEn^#tGn_C3B@_JviIlJu8S5S1MvK;{8^+VL%5EwC4oW> zal#9hZ+J+zX6hRYiSVqB+HY1O5c?gfIqyaXID?=Art*#I+n*b7?|FH20?5#FJ?9n% zghFu(S0qfjxbVfv>5Oo3$>l-<%xJ0y8%lioPwvsllV%IH1p{#(>i$03D;|5)B{ zrD}QFjJI|)M%oj{AMbvxitPhj%Q@K$N!!PCgYLT$>3#-T?w3*g1idK-r>6r4EBUrP zv2@?cVXZ^E+0Dyf1;~hJtK^?)dxk^+WbFq9pwCTyS`WM|s#_q$kP*HdnuMp{cP>z5 z(m2TNBh+p7No{+O51Lhb^7mtGB2|0eLbt)8o5`;}96%IUCwAxSUHr&VEG3_FaAUnq zRiHVa{s4J?OmKq^7lkNMcG;M-;kvh8RLUrNA#8`6%Q`CRS(|OrZ*U`Xg z*9DPM0B6&BN%4J#2YOBMbknI%f1J;U4j^3?0tcs#H0eY(#G_UyIxtZ%Gcm&8uZC@W zEDPigdHF?1YTo&U+^It%8!u_B-isZ^Nk_)_(CVTrn&&CpK>?~Z&?6od#riBr!x*jP zp#kmggIJhh@%Ox%*k`L}z{^M>QU%GF_(GJ$)Wv3ARrV_PffYg4)JNmy+8^s2ZpQO$ zn=f77y%axz`&`XFR99UBf!;B3@D;wp>NAN8CqPAA`JHHmq6i&15z{4+^lX34$r z$-JIK1#{P@N`D^=tFB4?JF=ij|Z9J>m$x+>viQZ zH+y(_KDQ16rN-u%k9|`gsW%-Jf}V7^i>nsFo;-Kg97--g$BOJ8>0r`*2cRkKW&3hz zBlk*nQ^?53BzNC{m=nYOC2!5=R>Q3LjKaa-{dSZ{+6&!UJ~oFB1EgPef1EA0K|}+d zf!yU4R8XHMe@ycG#dQNZ4W_}yQ&iO}GMx5fWl>>0cXyW$-Eaj+U4tL+meTP9JxOOx z;;38cN1W(ZXbHy3C6fA*aIfY?A4~x=V*Ifn4%ah0(GbcS;gU2+Utq|aK zS`d(eFK(9^S|`xig>3U$`1M#KMUD*G!S8pXja~C_K|k*vA&##aM*%f;mMR#$!1~RK zRZzYMBl=)1hJ#tGvaM#lo9@XqWPp^cg4S{(>1!jSl!V$4i<%O1{FQ$?cTWV~OAVf_o3$+Nh#` zjIfD_*f`%?{jP8TRhSKGt9i+worPjzEj;}*y8Jz-k}Bmj2*M$+IV|jLzZ0e+KsB2? zD|Hrj#i@0U|793lzI|&d=_CgWoC>^dM zgNdQq7o8wQniVO&g8k%_l#bn)(eT{pQ;j08tCo7q?68*URR6lK*`R(L8nGB=_(X%E?0zqEjILmSlecNTRQ%0(Gx9S{j5|u{6apoE`$oB{m({D< z3UFu%=v}$j(%+Ki#3yRqEypfL#2(&WN3}Bu^G>m77o01KOtIxIAl>>M*%Thbft-wZ!g)QM=+cw+i`W+t9mvnK z)k}rPHqzKG_}mJ#HNrwdXJj6?lbM^9qZdzSv_R%paM$p@yT%I21%Oe^tc62FcVLhz z52Jej(ISDl=JuNTEnt*+-_&PIhA=QoRJ!;Ir53=K!@vWXznEZ}?$%uc!^#(=NUFG3 z2;Eb3wu{{p)JV0w@M9{e8f^<-Iv^p^BBG%2k7+wtH>%gHT4y?!9y(@Kt)pCPt?(wW zqFphIOjojSa>8@a14wr=X`(A_M?j#AmwXKL~+>i z4`1E+U#95) + * @copyright 2020 Aligent Consulting. + * @license + * @link http://www.aligent.com.au/ + */ + +namespace Aligent\AsyncEventsBundle\Tests\Unit\DependencyInjection; + +use Aligent\AsyncEventsBundle\DependencyInjection\AligentAsyncEventsExtension; +use Oro\Bundle\TestFrameworkBundle\Test\DependencyInjection\ExtensionTestCase; + +class AligentAsyncEventsExtensionTest extends ExtensionTestCase +{ + public function testLoad() + { + $this->loadExtension(new AligentAsyncEventsExtension()); + + $expectedDefinitions = [ + \Aligent\AsyncEventsBundle\Security\WebhookAuthenticator::class, + \Aligent\AsyncEventsBundle\Provider\WebhookIntegrationProvider::class, + \Aligent\AsyncEventsBundle\EventListener\WebhookLoggingEventListener::class, + \Aligent\AsyncEventsBundle\Integration\WebhookChannel::class, + \Aligent\AsyncEventsBundle\Integration\WebhookTransport::class + ]; + $this->assertDefinitionsLoaded($expectedDefinitions); + } + + public function testGetAlias() + { + $extension = new AligentAsyncEventsExtension(); + + $this->assertEquals('aligent_async_events', $extension->getAlias()); + } +} diff --git a/Tests/Unit/Entity/WebhookTransportTest.php b/Tests/Unit/Entity/WebhookTransportTest.php new file mode 100644 index 0000000..3ca5c47 --- /dev/null +++ b/Tests/Unit/Entity/WebhookTransportTest.php @@ -0,0 +1,87 @@ + + * @copyright 2020 Aligent Consulting. + * @license + * @link http://www.aligent.com.au/ + */ + +namespace Aligent\AsyncEventsBundle\Tests\Unit\Entity; + +use Aligent\AsyncEventsBundle\Entity\WebhookTransport; +use Oro\Bundle\IntegrationBundle\Entity\Channel; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\ParameterBag; + +class WebhookTransportTest extends TestCase +{ + public function testGettersAndSetters() + { + $entity = new WebhookTransport(); + + $this->assertEquals( + null, + $entity->getUsername() + ); + $this->assertEquals( + null, + $entity->getPassword() + ); + $this->assertEquals( + null, + $entity->getChannel() + ); + $this->assertEquals( + new ParameterBag( + [ + 'username' => null, + 'password' => null, + 'url' => null, + 'entity' => null, + 'event' => null, + 'method' => null, + 'headers' => [] + ] + ), + $entity->getSettingsBag() + ); + + $username = 'TestUser'; + $password = 'TestPassword'; + $channel = new Channel(); + $entity->setUsername($username); + $entity->setPassword($password); + $entity->setChannel($channel); + + $this->assertEquals( + $username, + $entity->getUsername() + ); + $this->assertEquals( + $password, + $entity->getPassword() + ); + $this->assertSame( + $channel, + $entity->getChannel() + ); + $this->assertEquals( + new ParameterBag( + [ + 'username' => $username, + 'password' => $password, + 'url' => null, + 'entity' => null, + 'event' => null, + 'method' => null, + 'headers' => [] + ] + ), + $entity->getSettingsBag() + ); + } +} diff --git a/Tests/Unit/Form/Type/WebhookTransportSettingsTypeTest.php b/Tests/Unit/Form/Type/WebhookTransportSettingsTypeTest.php new file mode 100644 index 0000000..57d5843 --- /dev/null +++ b/Tests/Unit/Form/Type/WebhookTransportSettingsTypeTest.php @@ -0,0 +1,102 @@ + + * @copyright 2020 Aligent Consulting. + * @license + * @link http://www.aligent.com.au/ + */ + +namespace Aligent\AsyncEventsBundle\Tests\Unit\Form\Type; + +use Aligent\AsyncEventsBundle\Entity\WebhookTransport; +use Aligent\AsyncEventsBundle\Form\Type\WebhookTransportSettingsType; +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\Mapping\ClassMetadata; +use Doctrine\Persistence\Mapping\ClassMetadataFactory; +use Doctrine\Persistence\ObjectManager; +use Oro\Bundle\CatalogBundle\Entity\Category; +use Oro\Bundle\FormBundle\Form\Type\CollectionType; +use Oro\Bundle\FormBundle\Form\Type\OroChoiceType; +use Oro\Bundle\FormBundle\Form\Type\OroEncodedPlaceholderPasswordType; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\Extension\Core\Type\UrlType; +use Symfony\Component\Form\FormBuilderInterface; + +class WebhookTransportSettingsTypeTest extends TestCase +{ + /** + * @var WebhookTransportSettingsType + */ + private $type; + + protected function setUp(): void + { + $doctrine = $this->createMock(ManagerRegistry::class); + $objectManager = $this->createMock(ObjectManager::class); + $metadataFactory = $this->createMock(ClassMetadataFactory::class); + /** @var ClassMetadata[] */ + $metadata = $this->createMock(ClassMetadata::class); + + $doctrine->expects($this->any()) + ->method('getManager') + ->willReturn($objectManager); + + $objectManager->expects($this->any()) + ->method('getMetadataFactory') + ->willReturn($metadataFactory); + + $metadataFactory->expects($this->any()) + ->method('getAllMetadata') + ->willReturn($metadata); + + $this->type = new WebhookTransportSettingsType($doctrine); + } + + public function testBuildForm() + { + $builder = $this->getMockBuilder(FormBuilderInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + $expectedFields = [ + 'username' => TextType::class, + 'password' => OroEncodedPlaceholderPasswordType::class, + 'entity' => OroChoiceType::class, + 'event' => OroChoiceType::class, + 'method' => ChoiceType::class, + 'url' => UrlType::class, + 'headers' => CollectionType::class + ]; + + $builder->expects($this->exactly(count($expectedFields))) + ->method('add') + ->will($this->returnSelf()); + + $counter = 0; + foreach ($expectedFields as $fieldName => $formType) { + $builder->expects($this->at($counter)) + ->method('add') + ->with($fieldName, $formType) + ->will($this->returnSelf()); + $counter++; + } + + $this->type->buildForm($builder, []); + } + + public function testConfigureOptions() + { + $resolver = $this->createMock('Symfony\Component\OptionsResolver\OptionsResolver'); + $resolver + ->expects($this->once()) + ->method('setDefault') + ->with('data_class', WebhookTransport::class); + $this->type->configureOptions($resolver); + } +} diff --git a/composer.json b/composer.json index 0fd34ac..c633ef8 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,6 @@ } }, "require": { - "php": "~7.4.14 || ~8.0.0" + "oro/commerce": "5.0.*" } } From 1bbbe18854651ad03a332ad01b531cdfedd47185 Mon Sep 17 00:00:00 2001 From: Greg Ziborov Date: Thu, 24 Nov 2022 13:23:29 +1030 Subject: [PATCH 2/5] ORIO-65: Use statements fixed in twig templates to the new syntax --- Resources/views/FailedJob/index.html.twig | 2 +- Resources/views/FailedJob/view.html.twig | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Resources/views/FailedJob/index.html.twig b/Resources/views/FailedJob/index.html.twig index c4ffc44..d20144e 100644 --- a/Resources/views/FailedJob/index.html.twig +++ b/Resources/views/FailedJob/index.html.twig @@ -1,3 +1,3 @@ -{% extends 'OroUIBundle:actions:index.html.twig' %} +{% extends '@OroUIBundle/actions/index.html.twig' %} {% set gridName = 'aligent-failed-jobs-grid' %} {% set pageTitle = 'aligent.async.failedjob.entity.plural_label'|trans %} \ No newline at end of file diff --git a/Resources/views/FailedJob/view.html.twig b/Resources/views/FailedJob/view.html.twig index 98c9448..b96954d 100644 --- a/Resources/views/FailedJob/view.html.twig +++ b/Resources/views/FailedJob/view.html.twig @@ -1,5 +1,5 @@ -{% extends 'OroUIBundle:actions:view.html.twig' %} -{% import 'OroUIBundle::macros.html.twig' as ui %} +{% extends '@OroUIBundle/actions:view.html.twig' %} +{% import '@OroUIBundle/macros.html.twig' as ui %} {% oro_title_set({ titleTemplate : "%title%", params : { From 114a6f0b52e9c6c82e7ecd229d2aa687d55249df Mon Sep 17 00:00:00 2001 From: Greg Ziborov Date: Thu, 24 Nov 2022 13:24:10 +1030 Subject: [PATCH 3/5] ORION-65: Added checks to schema migrations --- .../AligentAsyncEventsBundleInstaller.php | 27 ++++++++++----- .../Schema/v1_1/WebhookBundleMigration.php | 33 +++++++++++++----- .../Schema/v1_2/WebhookBundleMigration.php | 34 ------------------- 3 files changed, 42 insertions(+), 52 deletions(-) delete mode 100644 Migrations/Schema/v1_2/WebhookBundleMigration.php diff --git a/Migrations/Schema/AligentAsyncEventsBundleInstaller.php b/Migrations/Schema/AligentAsyncEventsBundleInstaller.php index c060761..55114e7 100644 --- a/Migrations/Schema/AligentAsyncEventsBundleInstaller.php +++ b/Migrations/Schema/AligentAsyncEventsBundleInstaller.php @@ -36,14 +36,23 @@ public function up(Schema $schema, QueryBag $queries) */ protected function createAligentFailedJobTable(Schema $schema) { - $table = $schema->createTable('aligent_failed_job'); - $table->addColumn('id', 'integer', ['autoincrement' => true]); - $table->addColumn('topic', 'string', ['length' => 255]); - $table->addColumn('body', 'json_array', ['comment' => '(DC2Type:json_array)']); - $table->addColumn('exception', 'string', ['notnull' => false, 'length' => 255]); - $table->addColumn('trace', 'text', ['notnull' => false]); - $table->addColumn('created_at', 'datetime', ['comment' => '(DC2Type:datetime)']); - $table->addColumn('updated_at', 'datetime', ['comment' => '(DC2Type:datetime)']); - $table->setPrimaryKey(['id']); + if (!$schema->hasTable('aligent_failed_job')) { + $table = $schema->createTable('aligent_failed_job'); + $table->addColumn('id', 'integer', ['autoincrement' => true]); + $table->addColumn('topic', 'string', ['length' => 255]); + $table->addColumn('body', 'json_array', ['comment' => '(DC2Type:json_array)']); + $table->addColumn('exception', 'text', ['notnull' => false, 'length' => 255]); + $table->addColumn('trace', 'text', ['notnull' => false]); + $table->addColumn('created_at', 'datetime', ['comment' => '(DC2Type:datetime)']); + $table->addColumn('updated_at', 'datetime', ['comment' => '(DC2Type:datetime)']); + $table->addColumn('wh_entity_class', 'string', ['notnull' => false, 'length' => 255]); + $table->addColumn('wh_event', 'string', ['notnull' => false, 'length' => 16]); + $table->addColumn('wh_method', 'string', ['notnull' => false, 'length' => 16]); + $table->addColumn('wh_headers', 'json', ['notnull' => false]); + $table->addColumn('wh_api_url', 'string', ['notnull' => false]); + $table->addColumn('wh_api_key', 'string', ['notnull' => false]); + $table->addColumn('wh_api_user', 'string', ['notnull' => false]); + $table->setPrimaryKey(['id']); + } } } diff --git a/Migrations/Schema/v1_1/WebhookBundleMigration.php b/Migrations/Schema/v1_1/WebhookBundleMigration.php index 22fe2f5..66b5348 100644 --- a/Migrations/Schema/v1_1/WebhookBundleMigration.php +++ b/Migrations/Schema/v1_1/WebhookBundleMigration.php @@ -1,9 +1,8 @@ getTable('aligent_failed_job'); - $table->changeColumn( - 'exception', - [ - 'type' => TextType::getType('text') - ] - ); + $table = $schema->getTable('oro_integration_transport'); + + if (!$table->hasColumn('wh_entity_class')) { + $table->addColumn('wh_entity_class', 'string', ['notnull' => false, 'length' => 255]); + } + if (!$table->hasColumn('wh_event')) { + $table->addColumn('wh_event', 'string', ['notnull' => false, 'length' => 16]); + } + if (!$table->hasColumn('wh_method')) { + $table->addColumn('wh_method', 'string', ['notnull' => false, 'length' => 16]); + } + if (!$table->hasColumn('wh_headers')) { + $table->addColumn('wh_headers', 'json', ['notnull' => false]); + } + if (!$table->hasColumn('wh_api_url')) { + $table->addColumn('wh_api_url', 'string', ['notnull' => false]); + } + if (!$table->hasColumn('wh_api_key')) { + $table->addColumn('wh_api_key', 'string', ['notnull' => false]); + } + if (!$table->hasColumn('wh_api_user')) { + $table->addColumn('wh_api_user', 'string', ['notnull' => false]); + } } } diff --git a/Migrations/Schema/v1_2/WebhookBundleMigration.php b/Migrations/Schema/v1_2/WebhookBundleMigration.php deleted file mode 100644 index 16e0b18..0000000 --- a/Migrations/Schema/v1_2/WebhookBundleMigration.php +++ /dev/null @@ -1,34 +0,0 @@ - - * @copyright 2020 Aligent Consulting. - * @link http://www.aligent.com.au/ - */ -class WebhookBundleMigration implements Migration -{ - /** - * @inheritDoc - */ - public function up(Schema $schema, QueryBag $queries) - { - $table = $schema->getTable('oro_integration_transport'); - $table->addColumn('wh_entity_class', 'string', ['notnull' => false, 'length' => 255]); - $table->addColumn('wh_event', 'string', ['notnull' => false, 'length' => 16]); - $table->addColumn('wh_method', 'string', ['notnull' => false, 'length' => 16]); - $table->addColumn('wh_headers', 'json', ['notnull' => false]); - $table->addColumn('wh_api_url', 'string', ['notnull' => false]); - $table->addColumn('wh_api_key', 'string', ['notnull' => false]); - $table->addColumn('wh_api_user', 'string', ['notnull' => false]); - } -} From 1248f4c378c7854e952aa2993eae9f1dfe7da7a0 Mon Sep 17 00:00:00 2001 From: Greg Ziborov Date: Thu, 24 Nov 2022 13:25:31 +1030 Subject: [PATCH 4/5] ORION-65: Few missed AsyncBundle strings changed to AsyncEventsBundle --- Entity/FailedJob.php | 2 +- Resources/config/oro/datagrids.yml | 2 +- composer.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Entity/FailedJob.php b/Entity/FailedJob.php index 41610c8..6709f74 100644 --- a/Entity/FailedJob.php +++ b/Entity/FailedJob.php @@ -18,7 +18,7 @@ /** * Class FailedJob - * @package Aligent\AsyncBundle\Entity + * @package Aligent\AsyncEventsBundle\Entity * @ORM\Entity() * @ORM\Table( * name="aligent_failed_job", diff --git a/Resources/config/oro/datagrids.yml b/Resources/config/oro/datagrids.yml index bad3260..be0a05d 100644 --- a/Resources/config/oro/datagrids.yml +++ b/Resources/config/oro/datagrids.yml @@ -9,7 +9,7 @@ datagrids: - job.id - job.createdAt from: - - { table: AligentAsyncBundle:FailedJob, alias: job } + - { table: AligentAsyncEventsBundle:FailedJob, alias: job } columns: topic: label: aligent.async.failedjob.topic.label diff --git a/composer.json b/composer.json index c633ef8..138fa53 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ ], "autoload": { "psr-4": { - "Aligent\\AsyncBundle\\": "" + "Aligent\\AsyncEventsBundle\\": "" } }, "require": { From ceae1565b53db861a17d9aa50888b7d6b21fa500 Mon Sep 17 00:00:00 2001 From: Greg Ziborov Date: Tue, 17 Jan 2023 10:02:16 +1030 Subject: [PATCH 5/5] Added empty url as param to sendWebhookEvent --- Integration/WebhookTransport.php | 1 + 1 file changed, 1 insertion(+) diff --git a/Integration/WebhookTransport.php b/Integration/WebhookTransport.php index 4d9830b..3310553 100644 --- a/Integration/WebhookTransport.php +++ b/Integration/WebhookTransport.php @@ -92,6 +92,7 @@ public function sendWebhookEvent($method = 'POST', $payload = []) { return $this->client->request( $method, + '', [ 'json' => $payload ]