Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
<?php
namespace Mautic\LeadBundle\Model;
/**
* Interface ChannelTimelineInterface.
*/
interface ChannelTimelineInterface
{
/**
* Return the name of a template to use to customize the channel's timeline entry.
*
* Return an empty value to ignore
*
* @param string $eventType
* @param array $details
*
* @return mixed
*/
public function getChannelTimelineTemplate($eventType, $details);
/**
* Override the timeline name for this channel's timeline entry.
*
* Return an empty value to ignore
*
* @param string $eventType
* @param array $details
*
* @return mixed
*/
public function getChannelTimelineLabel($eventType, $details);
/**
* Override the icon for this channel's timeline entry.
*
* Return an empty value to ignore
*
* @param string $eventType
* @param array $details
*
* @return mixed
*/
public function getChannelTimelineIcon($eventType, $details);
}

View File

@@ -0,0 +1,93 @@
<?php
namespace Mautic\LeadBundle\Model;
use Mautic\LeadBundle\Entity\LeadField;
use Mautic\ReportBundle\Event\ReportGeneratorEvent;
use Symfony\Contracts\Translation\TranslatorInterface;
class CompanyReportData
{
public function __construct(
private FieldModel $fieldModel,
private TranslatorInterface $translator,
) {
}
public function getCompanyData(): array
{
$companyColumns = $this->getCompanyColumns();
$companyFields = $this->fieldModel->getEntities([
'filter' => [
'force' => [
[
'column' => 'f.object',
'expr' => 'like',
'value' => 'company',
],
],
],
]);
return array_merge($companyColumns, $this->getFieldColumns($companyFields, 'comp.'));
}
public function eventHasCompanyColumns(ReportGeneratorEvent $event): bool
{
$companyColumns = $this->getCompanyData();
foreach ($companyColumns as $key => $column) {
if ($event->hasColumn($key)) {
return true;
}
}
return false;
}
private function getCompanyColumns(): array
{
return [
'comp.id' => [
'alias' => 'comp_id',
'label' => 'mautic.lead.report.company.company_id',
'type' => 'int',
'link' => 'mautic_company_action',
],
'companies_lead.is_primary' => [
'label' => 'mautic.lead.report.company.is_primary',
'type' => 'bool',
],
'companies_lead.date_added' => [
'label' => 'mautic.lead.report.company.date_added',
'type' => 'datetime',
],
];
}
/**
* @param LeadField[] $fields
* @param string $prefix
*/
private function getFieldColumns($fields, $prefix): array
{
$columns = [];
foreach ($fields as $f) {
$type = match ($f->getType()) {
'boolean' => 'bool',
'date' => 'date',
'datetime' => 'datetime',
'time' => 'time',
'url' => 'url',
'email' => 'email',
'number' => 'float',
default => 'string',
};
$columns[$prefix.$f->getAlias()] = [
'label' => $this->translator->trans('mautic.report.field.company.label', ['%field%' => $f->getLabel()]),
'type' => $type,
];
}
return $columns;
}
}

View File

@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Model;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\ExportHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\AbstractCommonModel;
use Mautic\CoreBundle\Model\IteratorExportDataModel;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\EmailBundle\Helper\MailHelper;
use Mautic\LeadBundle\Entity\ContactExportScheduler;
use Mautic\LeadBundle\Entity\ContactExportSchedulerRepository;
use Mautic\UserBundle\Entity\User;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* @extends AbstractCommonModel<ContactExportScheduler>
*/
class ContactExportSchedulerModel extends AbstractCommonModel
{
private const EXPORT_FILE_NAME_DATE_FORMAT = 'Y_m_d_H_i_s';
public function __construct(
private RequestStack $requestStack,
private LeadModel $leadModel,
private ExportHelper $exportHelper,
private MailHelper $mailHelper,
EntityManager $em,
CorePermissions $security,
EventDispatcherInterface $dispatcher,
UrlGeneratorInterface $router,
Translator $translator,
UserHelper $userHelper,
LoggerInterface $mauticLogger,
CoreParametersHelper $coreParametersHelper,
) {
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
}
public function getRepository(): ContactExportSchedulerRepository
{
/** @var ContactExportSchedulerRepository $repo */
$repo = $this->em->getRepository(ContactExportScheduler::class);
return $repo;
}
/**
* @param array<mixed> $permissions
*
* @return array<string, mixed>
*/
public function prepareData(array $permissions): array
{
$search = $this->requestStack->getSession()->get('mautic.lead.filter', '');
$orderBy = $this->requestStack->getSession()->get('mautic.lead.orderby', 'l.last_active');
$orderByDir = $this->requestStack->getSession()->get('mautic.lead.orderbydir', 'DESC');
$indexMode = $this->requestStack->getSession()->get('mautic.lead.indexmode', 'list');
$anonymous = $this->translator->trans('mautic.lead.lead.searchcommand.isanonymous');
/** @var Request $request */
$request = $this->getRequest();
$ids = $request->get('ids');
$fileType = $request->get('filetype', 'csv');
$filter = ['string' => $search, 'force' => []];
if (!empty($ids)) {
$filter['force'] = [
[
'column' => 'l.id',
'expr' => 'in',
'value' => json_decode($ids, true, 512, JSON_THROW_ON_ERROR),
],
];
} else {
if ('list' !== $indexMode || (!str_contains($search, $anonymous))) {
// Remove anonymous leads unless requested to prevent clutter.
$filter['force'] = [
[
'column' => 'l.dateIdentified',
'expr' => 'isNotNull',
],
];
}
if (!$permissions['lead:leads:viewother']) {
// Show only owner's contacts.
$filter['force'] = [
[
'column' => 'l.owner',
'expr' => 'eq',
],
];
}
}
return [
'start' => 0,
'limit' => $this->coreParametersHelper->get('contact_export_batch_size', 1000),
'filter' => $filter,
'orderBy' => $orderBy,
'orderByDir' => $orderByDir,
'withTotalCount' => true,
'fileType' => $fileType,
];
}
/**
* @param array<mixed> $data
*/
public function saveEntity(array $data): ContactExportScheduler
{
$contactExportScheduler = new ContactExportScheduler();
$contactExportScheduler
->setUser($this->userHelper->getUser())
->setScheduledDateTime(new \DateTimeImmutable())
->setData($data);
$this->em->persist($contactExportScheduler);
$this->em->flush();
return $contactExportScheduler;
}
public function processAndGetExportFilePath(ContactExportScheduler $contactExportScheduler): string
{
$data = $contactExportScheduler->getData();
$fileType = $data['fileType'];
$resultsCallback = fn ($contact) => $contact->getProfileFields();
$iterator = new IteratorExportDataModel(
$this->leadModel,
$contactExportScheduler->getData(),
$resultsCallback,
true
);
/** @var \DateTimeImmutable $scheduledDateTime */
$scheduledDateTime = $contactExportScheduler->getScheduledDateTime();
$fileName = 'contacts_export_'.$scheduledDateTime->format(self::EXPORT_FILE_NAME_DATE_FORMAT);
return $this->exportResultsAs($iterator, $fileType, $fileName);
}
public function getEmailMessageWithLink(string $filePath): string
{
$link = $this->router->generate(
'mautic_contact_export_download',
['fileName' => basename($filePath)],
UrlGeneratorInterface::ABSOLUTE_URL
);
return $this->translator->trans(
'mautic.lead.export.email',
['%link%' => $link, '%label%' => basename($filePath)]
);
}
public function sendEmail(ContactExportScheduler $contactExportScheduler, string $filePath): void
{
/** @var User $user */
$user = $contactExportScheduler->getUser();
$message = $this->getEmailMessageWithLink($filePath);
$this->mailHelper->setTo([$user->getEmail() => $user->getName()]);
$this->mailHelper->setSubject(
$this->translator->trans('mautic.lead.export.email_subject', ['%file_name%' => basename($filePath)])
);
$this->mailHelper->setBody($message);
$this->mailHelper->parsePlainText($message);
$this->mailHelper->send(true);
}
public function deleteEntity(ContactExportScheduler $contactExportScheduler): void
{
$this->em->remove($contactExportScheduler);
$this->em->flush();
}
public function getExportFileToDownload(string $fileName): BinaryFileResponse
{
$filePath = $this->coreParametersHelper->get('contact_export_dir').'/'.$fileName;
$contentType = $this->getContactExportFileContentType($fileName);
return new BinaryFileResponse(
$filePath,
Response::HTTP_OK,
[
'Content-Type' => $contentType,
'Content-Disposition' => 'attachment; filename="'.$fileName.'"',
'Expires' => 0,
'Cache-Control' => 'must-revalidate',
'Pragma' => 'public',
]
);
}
private function getRequest(): ?Request
{
return $this->requestStack->getCurrentRequest();
}
private function exportResultsAs(IteratorExportDataModel $iterator, string $fileType, string $fileName): string
{
if (!in_array($fileType, $this->exportHelper->getSupportedExportTypes(), true)) {
throw new BadRequestHttpException($this->translator->trans('mautic.error.invalid.export.type', ['%type%' => $fileType]));
}
$csvFilePath = $this->exportHelper
->exportDataIntoFile($iterator, $fileType, strtolower($fileName.'.'.$fileType));
return $this->exportHelper->zipFile($csvFilePath, 'contacts_export.csv');
}
private function getContactExportFileContentType(string $fileName): string
{
$ext = pathinfo($fileName, PATHINFO_EXTENSION);
if ('zip' === $ext) {
return 'application/zip';
}
throw new BadRequestHttpException($this->translator->trans('mautic.error.invalid.specific.export.type', ['%type%' => $ext, '%expected_type%' => 'zip']));
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Mautic\LeadBundle\Model;
use Mautic\LeadBundle\Entity\CustomFieldEntityInterface;
trait DefaultValueTrait
{
/**
* @param string $object
*/
protected function setEntityDefaultValues(CustomFieldEntityInterface $entity, $object = 'lead')
{
if (!$entity->getId()) {
$fields = $this->leadFieldModel->getFieldListWithProperties($object);
foreach ($fields as $alias => $field) {
// Prevent defaults from overwriting values already set
$value = $entity->getFieldValue($alias);
if ((null === $value || '' === $value) && '' !== $field['defaultValue'] && null !== $field['defaultValue']) {
$entity->addUpdatedField($alias, $field['defaultValue']);
}
}
}
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace Mautic\LeadBundle\Model;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Entity\LeadDevice;
use Mautic\LeadBundle\Entity\LeadDeviceRepository;
use Mautic\LeadBundle\Event\LeadDeviceEvent;
use Mautic\LeadBundle\Form\Type\DeviceType;
use Mautic\LeadBundle\LeadEvents;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\EventDispatcher\Event;
/**
* @extends FormModel<LeadDevice>
*/
class DeviceModel extends FormModel
{
public function __construct(
private LeadDeviceRepository $leadDeviceRepository,
EntityManager $em,
CorePermissions $security,
EventDispatcherInterface $dispatcher,
UrlGeneratorInterface $router,
Translator $translator,
UserHelper $userHelper,
LoggerInterface $mauticLogger,
CoreParametersHelper $coreParametersHelper,
) {
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
}
/**
* @return LeadDeviceRepository
*/
public function getRepository()
{
return $this->leadDeviceRepository;
}
public function getPermissionBase(): string
{
return 'lead:leads';
}
/**
* Get a specific entity or generate a new one if id is empty.
*/
public function getEntity($id = null): ?LeadDevice
{
if (null === $id) {
return new LeadDevice();
}
return parent::getEntity($id);
}
/**
* @param array $options
*/
public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): \Symfony\Component\Form\FormInterface
{
if (!$entity instanceof LeadDevice) {
throw new MethodNotAllowedHttpException(['LeadDevice']);
}
if (!empty($action)) {
$options['action'] = $action;
}
return $formFactory->create(DeviceType::class, $entity, $options);
}
/**
* @throws MethodNotAllowedHttpException
*/
protected function dispatchEvent($action, &$entity, $isNew = false, ?Event $event = null): ?Event
{
if (!$entity instanceof LeadDevice) {
throw new MethodNotAllowedHttpException(['LeadDevice']);
}
switch ($action) {
case 'pre_save':
$name = LeadEvents::DEVICE_PRE_SAVE;
break;
case 'post_save':
$name = LeadEvents::DEVICE_POST_SAVE;
break;
case 'pre_delete':
$name = LeadEvents::DEVICE_PRE_DELETE;
break;
case 'post_delete':
$name = LeadEvents::DEVICE_POST_DELETE;
break;
default:
return null;
}
if ($this->dispatcher->hasListeners($name)) {
if (empty($event)) {
$event = new LeadDeviceEvent($entity, $isNew);
$event->setEntityManager($this->em);
}
$this->dispatcher->dispatch($event, $name);
return $event;
} else {
return null;
}
}
}

View File

@@ -0,0 +1,233 @@
<?php
namespace Mautic\LeadBundle\Model;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\PersistentCollection;
use Mautic\CacheBundle\Cache\CacheProvider;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Model\MauticModelInterface;
use Mautic\LeadBundle\Entity\DoNotContact as DNC;
use Mautic\LeadBundle\Entity\DoNotContactRepository;
use Mautic\LeadBundle\Entity\Lead;
class DoNotContact implements MauticModelInterface
{
public function __construct(
protected LeadModel $leadModel,
protected DoNotContactRepository $dncRepo,
protected CoreParametersHelper $coreParametersHelper,
protected CacheProvider $cacheProvider,
) {
}
/**
* Remove a Lead's DNC entry based on channel.
*
* @param int|Lead $contact
* @param string $channel
* @param bool|true $persist
* @param int|null $reason
*/
public function removeDncForContact($contact, $channel, $persist = true, $reason = null): bool
{
if (is_numeric($contact)) {
$contact = $this->leadModel->getEntity($contact);
}
if (null === $contact) {
return false;
}
/** @var DNC $dnc */
foreach ($contact->getDoNotContact() as $dnc) {
if ($dnc->getChannel() === $channel) {
// Skip if reason doesn't match
// Some integrations (Sugar CRM) can use both reasons (unsubscribed, bounced)
if ($reason && $dnc->getReason() != $reason) {
continue;
}
$contact->removeDoNotContactEntry($dnc);
if ($persist) {
$this->leadModel->saveEntity($contact);
}
return true;
}
}
return false;
}
/**
* Create a DNC entry for a lead.
*
* @param Lead|int|null $contact
* @param string|mixed[] $channel If an array with an ID, use the structure ['email' => 123]
* @param string $comments
* @param int $reason Must be a class constant from the DoNotContact class
* @param bool $persist
* @param bool $checkCurrentStatus
* @param bool $allowUnsubscribeOverride
*
* @return bool|DNC If a DNC entry is added or updated, returns the DoNotContact object. If a DNC is already present
* and has the specified reason, nothing is done and this returns false
*/
public function addDncForContact(
$contact,
$channel,
$reason = DNC::BOUNCED,
$comments = '',
$persist = true,
$checkCurrentStatus = true,
$allowUnsubscribeOverride = false,
) {
$dnc = false;
if (is_numeric($contact)) {
$contact = $this->leadModel->getEntity($contact);
}
if (null === $contact) {
// Contact not found, nothing to do
return false;
}
// if !$checkCurrentStatus, assume is contactable due to already being validated
$isContactable = ($checkCurrentStatus) ? $this->isContactable($contact, $channel) : DNC::IS_CONTACTABLE;
/** @var ArrayCollection<int, DNC> $dncEntities */
$dncEntities = new ArrayCollection();
// If they don't have a DNC entry yet
if (DNC::IS_CONTACTABLE === $isContactable) {
$dnc = $dncEntities[] = $this->createDncRecord($contact, $channel, $reason, $comments);
} elseif ($isContactable !== $reason) {
// Or if the given reason is different than the stated reason
$dncEntities = $contact->getDoNotContact();
foreach ($dncEntities as $dnc) {
// Only update if the contact did not unsubscribe themselves or if the code forces it
$allowOverride = ($allowUnsubscribeOverride || DNC::UNSUBSCRIBED !== $dnc->getReason());
// Only update if the contact did not unsubscribe themselves
if ($allowOverride && $dnc->getChannel() === $channel) {
// Note the outdated entry for listeners
$contact->removeDoNotContactEntry($dnc);
// Update the entry with the latest
$this->updateDncRecord($dnc, $contact, $channel, $reason, $comments);
break;
}
}
}
if (null !== $dnc && $persist) {
// Use model saveEntity to trigger events for DNC change
$this->leadModel->saveEntity($contact);
$this->dncRepo->detachEntities($dncEntities->toArray());
// need to force a collection to load items in the next call.
$collection = $contact->getDoNotContact();
if ($collection instanceof PersistentCollection) {
$collection->setInitialized(false);
}
}
return $dnc;
}
/**
* @param string $channel
*
* @return int
*
* @see DNC This method can return boolean false, so be
* sure to always compare the return value against
* the class constants of DoNotContact
*/
public function isContactable(Lead $contact, $channel)
{
if (is_array($channel)) {
$channel = key($channel);
}
$dncEntries = $this->dncRepo->getEntriesByLeadAndChannel($contact, $channel);
// If the lead has no entries in the DNC table, we're good to go
if (empty($dncEntries)) {
return DNC::IS_CONTACTABLE;
}
foreach ($dncEntries as $dnc) {
if (DNC::IS_CONTACTABLE !== $dnc->getReason()) {
return $dnc->getReason();
}
}
return DNC::IS_CONTACTABLE;
}
public function createDncRecord(Lead $contact, $channel, $reason, $comments = null): DNC
{
$dnc = new DNC();
if (is_array($channel)) {
$channelId = reset($channel);
$channel = key($channel);
$dnc->setChannelId((int) $channelId);
}
$dnc->setChannel($channel);
$dnc->setReason($reason);
$dnc->setLead($contact);
$dnc->setDateAdded(new \DateTime());
$dnc->setComments($comments);
$contact->addDoNotContactEntry($dnc);
return $dnc;
}
public function updateDncRecord(DNC $dnc, Lead $contact, $channel, $reason, $comments = null): void
{
// Update the DNC entry
$dnc->setChannel($channel);
$dnc->setReason($reason);
$dnc->setLead($contact);
$dnc->setDateAdded(new \DateTime());
$dnc->setComments($comments);
// Re-add the entry to the lead
$contact->addDoNotContactEntry($dnc);
}
/**
* Get all available reason-channel combinations.
*
* @return array<array{reason: int, channel: string}>
*/
public function getReasonChannelCombinations(bool $useCache = true): array
{
$cacheTimeout = (int) $this->coreParametersHelper->get('cached_data_timeout');
$cacheItem = $this->cacheProvider->getItem('dnc.reason_channel_combinations');
if ($useCache && $cacheItem->isHit()) {
return $cacheItem->get();
} else {
$reasonChannelCombinations = $this->dncRepo->getReasonChannelCombinations();
$cacheItem->set($reasonChannelCombinations);
$cacheItem->expiresAfter($cacheTimeout * 60);
$this->cacheProvider->save($cacheItem);
return $reasonChannelCombinations;
}
}
/**
* @return DoNotContactRepository
*/
public function getDncRepo()
{
return $this->dncRepo;
}
}

View File

@@ -0,0 +1,662 @@
<?php
namespace Mautic\LeadBundle\Model;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Exception\ORMException;
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
use Mautic\CoreBundle\Helper\Chart\LineChart;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\CsvHelper;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Helper\PathsHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\CoreBundle\Model\NotificationModel;
use Mautic\CoreBundle\ProcessSignal\ProcessSignalService;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Entity\Company;
use Mautic\LeadBundle\Entity\Import;
use Mautic\LeadBundle\Entity\ImportRepository;
use Mautic\LeadBundle\Entity\LeadEventLog;
use Mautic\LeadBundle\Entity\LeadEventLogRepository;
use Mautic\LeadBundle\Event\ImportEvent;
use Mautic\LeadBundle\Event\ImportProcessEvent;
use Mautic\LeadBundle\Exception\ImportDelayedException;
use Mautic\LeadBundle\Exception\ImportFailedException;
use Mautic\LeadBundle\Helper\Progress;
use Mautic\LeadBundle\LeadEvents;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\EventDispatcher\Event;
/**
* @extends FormModel<Import>
*/
class ImportModel extends FormModel
{
protected LeadEventLogRepository $leadEventLogRepo;
public function __construct(
protected PathsHelper $pathsHelper,
protected LeadModel $leadModel,
protected NotificationModel $notificationModel,
protected CoreParametersHelper $config,
protected CompanyModel $companyModel,
EntityManagerInterface $em,
CorePermissions $security,
EventDispatcherInterface $dispatcher,
UrlGeneratorInterface $router,
Translator $translator,
UserHelper $userHelper,
LoggerInterface $mauticLogger,
private ProcessSignalService $processSignalService,
) {
$this->leadEventLogRepo = $leadModel->getEventLogRepository();
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $config);
}
/**
* Returns the Import entity which should be processed next.
*
* @return Import|null
*/
public function getImportToProcess()
{
$result = $this->getRepository()->getImportsWithStatuses([Import::QUEUED, Import::DELAYED], 1);
if (isset($result[0]) && $result[0] instanceof Import) {
return $result[0];
}
return null;
}
/**
* Compares current number of imports in progress with the limit from the configuration.
*/
public function checkParallelImportLimit(): bool
{
$parallelImportLimit = $this->getParallelImportLimit();
$importsInProgress = $this->getRepository()->countImportsInProgress();
return !($importsInProgress >= $parallelImportLimit);
}
/**
* Returns parallel import limit from the configuration.
*
* @param int $default
*
* @return int
*/
public function getParallelImportLimit($default = 1)
{
return $this->config->get('parallel_import_limit', $default);
}
/**
* Generates a HTML link to the import detail.
*/
public function generateLink(Import $import, ?string $text = null): string
{
$linkText = $text ?? $import->getOriginalFile().' ('.$import->getId().')';
return '<a href="'.$this->router->generate(
'mautic_import_action',
['objectAction' => 'view', 'object' => 'lead', 'objectId' => $import->getId()]
).'" data-toggle="ajax">'.$linkText.'</a>';
}
/**
* Check if there are some IN_PROGRESS imports which got stuck for a while.
* Set those as failed.
*/
public function setGhostImportsAsFailed()
{
$ghostDelay = 2;
$imports = $this->getRepository()->getGhostImports($ghostDelay, 5);
if (empty($imports)) {
return null;
}
foreach ($imports as $import) {
$import->setStatus($import::FAILED)
->setStatusInfo($this->translator->trans('mautic.lead.import.ghost.limit.hit', ['%limit%' => $ghostDelay]))
->removeFile();
if ($import->getCreatedBy()) {
$this->notificationModel->addNotification(
$this->translator->trans(
'mautic.lead.import.result.info',
['%import%' => $this->generateLink($import)]
),
'info',
false,
$this->translator->trans('mautic.lead.import.failed', ['%reason%' => $import->getStatusInfo()]),
'ri-download-line',
null,
$this->em->getReference(\Mautic\UserBundle\Entity\User::class, $import->getCreatedBy())
);
}
}
$this->saveEntities($imports);
}
/**
* Start import. This is meant for the CLI command since it will import
* the whole file at once.
*
* @param int $limit Number of records to import before delaying the import. 0 will import all
* @param float $start Start time for timing calculation
*
* @throws ImportFailedException
* @throws ImportDelayedException
*/
public function beginImport(Import $import, Progress $progress, $limit = 0, ?float $start = null): void
{
if (null === $start) {
$start = microtime(true);
}
$this->setGhostImportsAsFailed();
if (!$import) {
$msg = 'import is empty, closing the import process';
$this->logDebug($msg, $import);
throw new ImportFailedException($msg);
}
if (!$import->canProceed()) {
$this->saveEntity($import);
$msg = 'import cannot be processed because '.$import->getStatusInfo();
$this->logDebug($msg, $import);
throw new ImportFailedException($msg);
}
if (!$this->checkParallelImportLimit()) {
$info = $this->translator->trans(
'mautic.lead.import.parallel.limit.hit',
['%limit%' => $this->getParallelImportLimit()]
);
$import->setStatus($import::DELAYED)->setStatusInfo($info);
$this->saveEntity($import);
$msg = 'import is delayed because parrallel limit was hit. '.$import->getStatusInfo();
$this->logDebug($msg, $import);
throw new ImportDelayedException($msg);
}
$processed = $import->getProcessedRows();
$total = $import->getLineCount();
$pending = $total - $processed;
if ($limit && $limit < $pending) {
$processed = 0;
$total = $limit;
}
$progress->setTotal($total);
$progress->setDone($processed);
$import->start();
// Save the start changes so the user could see it
$this->saveEntity($import);
$this->logDebug('The background import is about to start', $import);
try {
if (!$this->process($import, $progress, $limit)) {
throw new ImportFailedException($import->getStatusInfo());
}
} catch (ORMException $e) {
// The EntityManager is probably closed. The entity cannot be saved.
$info = $this->translator->trans(
'mautic.lead.import.database.exception',
['%message%' => $e->getMessage()]
);
$import->setStatus($import::DELAYED)->setStatusInfo($info);
throw new ImportFailedException('Database had been overloaded');
}
$import->end();
$this->logDebug('The background import has ended', $import);
// Save the end changes so the user could see it
$this->saveEntity($import);
if ($import->getCreatedBy()) {
$this->notificationModel->addNotification(
$this->translator->trans(
'mautic.lead.import.result',
[
'%lines%' => $import->getProcessedRows(),
'%created%' => $import->getInsertedCount(),
'%updated%' => $import->getUpdatedCount(),
'%ignored%' => $import->getIgnoredCount(),
'%time%' => round(microtime(true) - $start, 2),
]
),
'info',
false,
$this->generateLink($import, $this->translator->trans('mautic.lead.import.completed')),
'ri-download-line',
null,
$this->em->getReference(\Mautic\UserBundle\Entity\User::class, $import->getCreatedBy())
);
}
}
/**
* Import the CSV file from configuration in the $import entity.
*
* @param int $limit Number of records to import before delaying the import
*/
public function process(Import $import, Progress $progress, $limit = 0): bool
{
try {
$file = new \SplFileObject($import->getFilePath());
} catch (\Exception $e) {
$import->setStatusInfo('SplFileObject cannot read the file. '.$e->getMessage());
$import->setStatus(Import::FAILED);
$this->logDebug('import cannot be processed because '.$import->getStatusInfo(), $import);
return false;
}
$lastImportedLine = $import->getLastLineImported();
$headers = $import->getHeaders();
$headerCount = count($headers);
$config = $import->getParserConfig();
$counter = 0;
$file->seek($lastImportedLine);
$lineNumber = $lastImportedLine + 1;
$this->logDebug('The import is starting on line '.$lineNumber, $import);
$batchSize = $config['batchlimit'];
// Convert to field names
array_walk($headers, function (&$val): void {
$val = strtolower(InputHelper::alphanum($val, false, '_'));
});
while ($batchSize && !$file->eof()) {
$string = $file->current();
$file->next();
$data = CsvHelper::strGetCsv($string, $config['delimiter'], $config['enclosure'], $config['escape']);
$import->setLastLineImported($lineNumber);
// Ignore the header row
if (1 === $lineNumber) {
++$lineNumber;
continue;
}
// Ensure the progress is changing
++$lineNumber;
--$batchSize;
$progress->increase();
$errorMessage = null;
$eventLog = $this->initEventLog($import, $lineNumber);
if ($this->isEmptyCsvRow($data)) {
$errorMessage = 'mautic.lead.import.error.line_empty';
}
if ($this->hasMoreValuesThanColumns($data, $headerCount)) {
$errorMessage = 'mautic.lead.import.error.header_mismatch';
}
if (!$errorMessage) {
$data = $this->trimArrayValues($data);
if (!array_filter($data)) {
continue;
}
$data = array_combine($headers, $data);
$event = new ImportProcessEvent($import, $eventLog, $data);
try {
$this->dispatcher->dispatch($event, LeadEvents::IMPORT_ON_PROCESS);
if ($event->wasMerged()) {
$this->logDebug('Entity on line '.$lineNumber.' has been updated', $import);
$import->increaseUpdatedCount();
} else {
$this->logDebug('Entity on line '.$lineNumber.' has been created', $import);
$import->increaseInsertedCount();
}
} catch (\Exception $e) {
// Email validation likely failed
$errorMessage = $e->getMessage();
}
}
if ($errorMessage) {
// Log the error first
$import->increaseIgnoredCount();
$this->logDebug('Line '.$lineNumber.' error: '.$errorMessage, $import);
if (!$this->em->isOpen()) {
// Something bad must have happened if the entity manager is closed.
// We will not be able to save any entities.
throw new ORMException($errorMessage);
}
// This should be called only if the entity manager is open
$this->logImportRowError($eventLog, $errorMessage);
} else {
// adding warning logs for partial imports.
if ($event->getWarnings()) {
$eventLog->addProperty('error', implode('\n', $event->getWarnings()))
->setAction('failed');
}
$this->leadEventLogRepo->saveEntity($eventLog);
}
// Release entities in Doctrine's memory to prevent memory leak
$this->em->detach($eventLog);
if (null !== $leadEntity = $eventLog->getLead()) {
$this->em->detach($leadEntity);
$company = $leadEntity->getCompany();
$primaryCompany = $leadEntity->getPrimaryCompany();
if ($company instanceof Company) {
$this->em->detach($company);
}
if ($primaryCompany instanceof Company) {
$this->em->detach($primaryCompany);
}
}
$eventLog = null;
$data = null;
// Save Import entity once per batch so the user could see the progress
if (0 === $batchSize && $import->isBackgroundProcess()) {
$isPublished = $this->getRepository()->getValue($import->getId(), 'is_published');
if (!$isPublished) {
$import->setStatus($import::STOPPED);
}
$this->saveEntity($import);
$this->dispatchEvent('batch_processed', $import);
// Stop the import loop if the import got unpublished
if (!$isPublished) {
$this->logDebug('The import has been unpublished. Stopping the import now.', $import);
break;
}
$batchSize = $config['batchlimit'];
}
if ($this->processSignalService->isSignalCaught()) {
break;
}
++$counter;
if ($limit && $counter >= $limit) {
break;
}
}
$isPublished = (bool) $this->getRepository()->getValue($import->getId(), 'is_published');
if ($isPublished && $import->getLastLineImported() < $import->getLineCount()) {
$import->setStatus($import::DELAYED);
$this->saveEntity($import);
}
// Close the file
$file = null;
return true;
}
/**
* Check if the CSV row has more values than the CSV header has columns.
* If it is less, generate empty values for the rest of the missing values.
* If it is more, return true.
*
* @param int $headerCount
*/
public function hasMoreValuesThanColumns(array &$data, $headerCount): bool
{
$dataCount = count($data);
if ($headerCount !== $dataCount) {
$diffCount = ($headerCount - $dataCount);
if ($diffCount > 0) {
// Fill in the data with empty string
$fill = array_fill($dataCount, $diffCount, '');
$data = $data + $fill;
} else {
return true;
}
}
return false;
}
/**
* Trim all values in a one dymensional array.
*/
public function trimArrayValues(array $data): array
{
return array_map('trim', $data);
}
/**
* Decide whether the CSV row is empty.
*
* @param mixed $row
*/
public function isEmptyCsvRow($row): bool
{
if (!is_array($row) || empty($row)) {
return true;
}
if (1 === count($row) && ('' === $row[0] || null === $row[0])) {
return true;
}
return !array_filter($row);
}
/**
* Save log about errored line.
*
* @param string $errorMessage
*/
public function logImportRowError(LeadEventLog $eventLog, $errorMessage): void
{
$eventLog->addProperty('error', $this->translator->trans($errorMessage))
->setAction('failed');
$this->leadEventLogRepo->saveEntity($eventLog);
}
/**
* Initialize LeadEventLog object and configure it as the import event.
*
* @param int $lineNumber
*/
public function initEventLog(Import $import, $lineNumber): LeadEventLog
{
$eventLog = new LeadEventLog();
$eventLog->setUserId($import->getModifiedBy())
->setUserName($import->getModifiedByUser())
->setBundle($import->getObject())
->setObject('import')
->setObjectId($import->getId())
->setProperties(
[
'line' => $lineNumber,
'file' => $import->getOriginalFile(),
]
);
return $eventLog;
}
/**
* Get line chart data of imported rows.
*
* @param string $unit {@link php.net/manual/en/function.date.php#refsect1-function.date-parameters}
* @param string $dateFormat
* @param array $filter
*/
public function getImportedRowsLineChartData($unit, \DateTimeInterface $dateFrom, \DateTimeInterface $dateTo, $dateFormat = null, $filter = []): array
{
$filter['object'] = 'import';
$filter['bundle'] = 'lead';
// Clear the times for display by minutes
/** @var \DateTime $dateFrom */
/** @var \DateTime $dateTo */
$dateFrom->modify('-1 minute');
$dateFrom->setTime($dateFrom->format('H'), $dateFrom->format('i'), 0);
$dateTo->modify('+1 minute');
$dateTo->setTime($dateTo->format('H'), $dateTo->format('i'), 0);
$query = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo, $unit);
$chart = new LineChart($unit, $dateFrom, $dateTo, $dateFormat);
$data = $query->fetchTimeData('lead_event_log', 'date_added', $filter);
$chart->setDataset($this->translator->trans('mautic.lead.import.processed.rows'), $data);
return $chart->render();
}
/**
* Returns a list of failed rows for the import.
*
* @param int $importId
* @param string $object
*
* @return array|null
*/
public function getFailedRows($importId = null, $object = 'lead')
{
if (!$importId) {
return null;
}
return $this->getEventLogRepository()->getFailedRows($importId, ['select' => 'properties,id'], $object);
}
/**
* @return ImportRepository
*/
public function getRepository()
{
return $this->em->getRepository(Import::class);
}
/**
* @return LeadEventLogRepository
*/
public function getEventLogRepository()
{
return $this->em->getRepository(LeadEventLog::class);
}
public function getPermissionBase(): string
{
return 'lead:imports';
}
/**
* Returns a unique name of a CSV file based on time.
*/
public function getUniqueFileName(): string
{
return (new DateTimeHelper())->toUtcString('YmdHis').'.csv';
}
/**
* Returns a full path to the import dir.
*/
public function getImportDir(): string
{
return $this->pathsHelper->getImportLeadsPath();
}
/**
* Get a specific entity or generate a new one if id is empty.
*/
public function getEntity($id = null): ?Import
{
if (null === $id) {
return new Import();
}
return parent::getEntity($id);
}
/**
* @throws MethodNotAllowedHttpException
*/
protected function dispatchEvent($action, &$entity, $isNew = false, ?Event $event = null): ?Event
{
if (!$entity instanceof Import) {
throw new MethodNotAllowedHttpException(['Import']);
}
switch ($action) {
case 'pre_save':
$name = LeadEvents::IMPORT_PRE_SAVE;
break;
case 'post_save':
$name = LeadEvents::IMPORT_POST_SAVE;
break;
case 'pre_delete':
$name = LeadEvents::IMPORT_PRE_DELETE;
break;
case 'post_delete':
$name = LeadEvents::IMPORT_POST_DELETE;
break;
case 'batch_processed':
$name = LeadEvents::IMPORT_BATCH_PROCESSED;
break;
default:
return null;
}
if ($this->dispatcher->hasListeners($name)) {
if (empty($event)) {
$event = new ImportEvent($entity, $isNew);
$event->setEntityManager($this->em);
}
$this->dispatcher->dispatch($event, $name);
return $event;
} else {
return null;
}
}
/**
* Logs a debug message if in dev environment.
*
* @param string $msg
*/
protected function logDebug($msg, ?Import $import = null)
{
if (MAUTIC_ENV === 'dev') {
$importId = $import ? '('.$import->getId().')' : '';
$this->logger->debug(sprintf('IMPORT%s: %s', $importId, $msg));
}
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Mautic\LeadBundle\Model;
use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\Entity\IpAddress;
use Mautic\CoreBundle\Entity\IpAddressRepository;
use Mautic\LeadBundle\Entity\Lead;
use Psr\Log\LoggerInterface;
class IpAddressModel
{
private const DELETE_SIZE = 10000;
public function __construct(
protected EntityManager $entityManager,
protected LoggerInterface $logger,
) {
}
/**
* Saving IP Address references sometimes throws UniqueConstraintViolationException exception on Lead entity save.
* Rather pre-save the IP references here and catch the exception.
*/
public function saveIpAddressesReferencesForContact(Lead $contact): void
{
foreach ($contact->getIpAddresses() as $ipAddress) {
$this->insertIpAddressReference($contact, $ipAddress);
}
}
/**
* @param string $ip
*
* @return IpAddress|null
*/
public function findOneByIpAddress($ip)
{
return $this->entityManager->getRepository(IpAddress::class)->findOneByIpAddress($ip);
}
/**
* Tries to insert the Lead/IP relation and continues even if UniqueConstraintViolationException is thrown.
*/
private function insertIpAddressReference(Lead $contact, IpAddress $ipAddress): void
{
$ipAddressAdded = isset($contact->getChanges()['ipAddressList'][$ipAddress->getIpAddress()]);
if (!$ipAddressAdded || !$ipAddress->getId() || !$contact->getId()) {
return;
}
$qb = $this->entityManager->getConnection()->createQueryBuilder();
$values = [
'lead_id' => ':leadId',
'ip_id' => ':ipId',
];
$qb->insert(MAUTIC_TABLE_PREFIX.'lead_ips_xref');
$qb->values($values);
$qb->setParameter('leadId', $contact->getId());
$qb->setParameter('ipId', $ipAddress->getId());
try {
$qb->executeStatement();
} catch (UniqueConstraintViolationException) {
$this->logger->warning("The reference for contact {$contact->getId()} and IP address {$ipAddress->getId()} is already there. (Unique constraint)");
} catch (ForeignKeyConstraintViolationException) {
$this->logger->warning("The reference for contact {$contact->getId()} and IP address {$ipAddress->getId()} is already there. (Foreign key constraint)");
}
$this->entityManager->detach($ipAddress);
}
/**
* @throws \Doctrine\DBAL\Exception
*/
public function deleteUnusedIpAddresses(int $limit): int
{
/** @var IpAddressRepository $ipAddressRepo */
$ipAddressRepo = $this->entityManager->getRepository(IpAddress::class);
$ipIds = $ipAddressRepo->getUnusedIpAddressesIds($limit);
$chunkedIds = array_chunk($ipIds, self::DELETE_SIZE);
$count = 0;
foreach ($chunkedIds as $ids) {
$count += $ipAddressRepo->deleteUnusedIpAddresses($ids);
// Use sleep to recover from any potential table locks.
usleep(50000);
}
return $count;
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace Mautic\LeadBundle\Model;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadNote;
use Mautic\LeadBundle\Entity\LeadNoteRepository;
use Mautic\LeadBundle\Event\LeadNoteEvent;
use Mautic\LeadBundle\Form\Type\NoteType;
use Mautic\LeadBundle\LeadEvents;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\EventDispatcher\Event;
/**
* @extends FormModel<LeadNote>
*/
class NoteModel extends FormModel
{
public function __construct(
EntityManagerInterface $em,
CorePermissions $security,
EventDispatcherInterface $dispatcher,
UrlGeneratorInterface $router,
Translator $translator,
UserHelper $userHelper,
LoggerInterface $mauticLogger,
CoreParametersHelper $coreParametersHelper,
private RequestStack $requestStack,
) {
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
}
public function getRepository(): LeadNoteRepository
{
return $this->em->getRepository(LeadNote::class);
}
public function getPermissionBase(): string
{
return 'lead:notes';
}
/**
* Get a specific entity or generate a new one if id is empty.
*/
public function getEntity($id = null): ?LeadNote
{
if (null === $id) {
return new LeadNote();
}
return parent::getEntity($id);
}
/**
* @param string|null $action
* @param array $options
*/
public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): \Symfony\Component\Form\FormInterface
{
if (!$entity instanceof LeadNote) {
throw new MethodNotAllowedHttpException(['LeadNote']);
}
if (!empty($action)) {
$options['action'] = $action;
}
return $formFactory->create(NoteType::class, $entity, $options);
}
/**
* @throws MethodNotAllowedHttpException
*/
protected function dispatchEvent($action, &$entity, $isNew = false, ?Event $event = null): ?Event
{
if (!$entity instanceof LeadNote) {
throw new MethodNotAllowedHttpException(['LeadNote']);
}
switch ($action) {
case 'pre_save':
$name = LeadEvents::NOTE_PRE_SAVE;
break;
case 'post_save':
$name = LeadEvents::NOTE_POST_SAVE;
break;
case 'pre_delete':
$name = LeadEvents::NOTE_PRE_DELETE;
break;
case 'post_delete':
$name = LeadEvents::NOTE_POST_DELETE;
break;
default:
return null;
}
if ($this->dispatcher->hasListeners($name)) {
if (empty($event)) {
$event = new LeadNoteEvent($entity, $isNew);
$event->setEntityManager($this->em);
}
$this->dispatcher->dispatch($event, $name);
return $event;
} else {
return null;
}
}
/**
* @return mixed
*/
public function getNoteCount(Lead $lead, $useFilters = false)
{
$filter = ($useFilters) ? $this->requestStack->getSession()->get('mautic.lead.'.$lead->getId().'.note.filter', '') : null;
$noteType = ($useFilters) ? $this->requestStack->getSession()->get('mautic.lead.'.$lead->getId().'.notetype.filter', []) : null;
return $this->getRepository()->getNoteCount($lead->getId(), $filter, $noteType);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Mautic\LeadBundle\Model;
class SegmentActionModel
{
public function __construct(
private LeadModel $contactModel,
) {
}
public function addContacts(array $contactIds, array $segmentIds): void
{
$contacts = $this->contactModel->getLeadsByIds($contactIds);
foreach ($contacts as $contact) {
if (!$this->contactModel->canEditContact($contact)) {
continue;
}
$this->contactModel->addToLists($contact, $segmentIds);
}
$this->contactModel->saveEntities($contacts);
}
public function removeContacts(array $contactIds, array $segmentIds): void
{
$contacts = $this->contactModel->getLeadsByIds($contactIds);
foreach ($contacts as $contact) {
if (!$this->contactModel->canEditContact($contact)) {
continue;
}
$this->contactModel->removeFromLists($contact, $segmentIds);
}
$this->contactModel->saveEntities($contacts);
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace Mautic\LeadBundle\Model;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\LeadBundle\Entity\Tag;
use Mautic\LeadBundle\Entity\TagRepository;
use Mautic\LeadBundle\Event\TagEvent;
use Mautic\LeadBundle\Form\Type\TagEntityType;
use Mautic\LeadBundle\LeadEvents;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Contracts\EventDispatcher\Event;
/**
* @extends FormModel<Tag>
*/
class TagModel extends FormModel
{
/**
* @return TagRepository
*/
public function getRepository()
{
return $this->em->getRepository(Tag::class);
}
public function getPermissionBase(): string
{
return 'lead:leads';
}
/**
* Get a specific entity or generate a new one if id is empty.
*
* @param int $id
*/
public function getEntity($id = null): ?Tag
{
if (is_null($id)) {
return new Tag();
}
return parent::getEntity($id);
}
/**
* @param Tag $entity
* @param array $options
*/
public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): \Symfony\Component\Form\FormInterface
{
if (!$entity instanceof Tag) {
throw new MethodNotAllowedHttpException(['Tag']);
}
if (!empty($action)) {
$options['action'] = $action;
}
return $formFactory->create(TagEntityType::class, $entity, $options);
}
/**
* @throws MethodNotAllowedHttpException
*/
protected function dispatchEvent($action, &$entity, $isNew = false, ?Event $event = null): ?Event
{
if (!$entity instanceof Tag) {
throw new MethodNotAllowedHttpException(['Tag']);
}
switch ($action) {
case 'pre_save':
$name = LeadEvents::TAG_PRE_SAVE;
break;
case 'post_save':
$name = LeadEvents::TAG_POST_SAVE;
break;
case 'pre_delete':
$name = LeadEvents::TAG_PRE_DELETE;
break;
case 'post_delete':
$name = LeadEvents::TAG_POST_DELETE;
break;
default:
return null;
}
if ($this->dispatcher->hasListeners($name)) {
if (empty($event)) {
$event = new TagEvent($entity, $isNew);
$event->setEntityManager($this->em);
}
$this->dispatcher->dispatch($event, $name);
return $event;
}
return null;
}
}