Initial commit: CloudOps infrastructure platform
This commit is contained in:
@@ -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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']));
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user