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,896 @@
<?php
namespace Mautic\CampaignBundle\Model;
use Doctrine\DBAL\Exception;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\PersistentCollection;
use Mautic\CampaignBundle\CampaignEvents;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\Lead as CampaignLead;
use Mautic\CampaignBundle\Entity\LeadEventLogRepository;
use Mautic\CampaignBundle\Event as Events;
use Mautic\CampaignBundle\EventCollector\EventCollector;
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
use Mautic\CampaignBundle\Form\Type\CampaignType;
use Mautic\CampaignBundle\Helper\ChannelExtractor;
use Mautic\CampaignBundle\Membership\MembershipBuilder;
use Mautic\CampaignBundle\Model\Exceptions\CampaignAlreadyUnpublishedException;
use Mautic\CampaignBundle\Model\Exceptions\CampaignVersionMismatchedException;
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
use Mautic\CoreBundle\Helper\Chart\LineChart;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\FormModel as CommonFormModel;
use Mautic\CoreBundle\Model\GlobalSearchInterface;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\EmailBundle\Entity\StatRepository;
use Mautic\FormBundle\Entity\Form;
use Mautic\FormBundle\Model\FormModel;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\ListModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* @extends CommonFormModel<Campaign>
*/
class CampaignModel extends CommonFormModel implements GlobalSearchInterface
{
public function __construct(
protected ListModel $leadListModel,
protected FormModel $formModel,
private EventCollector $eventCollector,
private MembershipBuilder $membershipBuilder,
private ContactTracker $contactTracker,
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 \Mautic\CampaignBundle\Entity\CampaignRepository
*/
public function getRepository()
{
$repo = $this->em->getRepository(Campaign::class);
$repo->setCurrentUser($this->userHelper->getUser());
return $repo;
}
/**
* @return \Mautic\CampaignBundle\Entity\EventRepository
*/
public function getEventRepository()
{
return $this->em->getRepository(Event::class);
}
/**
* @return \Mautic\CampaignBundle\Entity\LeadRepository
*/
public function getCampaignLeadRepository()
{
return $this->em->getRepository(CampaignLead::class);
}
/**
* @return LeadEventLogRepository
*/
public function getCampaignLeadEventLogRepository()
{
return $this->em->getRepository(\Mautic\CampaignBundle\Entity\LeadEventLog::class);
}
public function getPermissionBase(): string
{
return 'campaign:campaigns';
}
/**
* @param object $entity
* @param string|null $action
* @param array $options
*/
public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): \Symfony\Component\Form\FormInterface
{
if (!$entity instanceof Campaign) {
throw new MethodNotAllowedHttpException(['Campaign']);
}
if (!empty($action)) {
$options['action'] = $action;
}
return $formFactory->create(CampaignType::class, $entity, $options);
}
/**
* Get a specific entity or generate a new one if id is empty.
*/
public function getEntity($id = null): ?Campaign
{
if (null === $id) {
return new Campaign();
}
return parent::getEntity($id);
}
/**
* Delete an array of campaigns.
*
* @param int[] $campaignIds
*
* @return array<int,Campaign>
*/
public function deleteEntities($campaignIds): array
{
$entities = [];
foreach ($campaignIds as $campaignId) {
$campaign = $this->getEntity($campaignId);
if ($campaign) {
$entities[$campaignId] = $campaign;
$this->deleteEntity($campaign);
}
}
return $entities;
}
public function deleteEntity($entity): void
{
// Null all the event parents for this campaign to avoid database constraints
$this->getEventRepository()->nullEventParents($entity->getId());
$this->dispatchEvent('pre_delete', $entity);
$this->getRepository()->setCampaignAsDeleted($entity->getId());
$this->dispatcher->dispatch(new Events\DeleteCampaign($entity), CampaignEvents::ON_CAMPAIGN_DELETE);
}
public function deleteCampaign(Campaign $campaign): void
{
$campaign->deletedId = $campaign->getId();
$this->getRepository()->deleteEntity($campaign);
$this->dispatchEvent('post_delete', $campaign);
}
/**
* @throws MethodNotAllowedHttpException
*/
protected function dispatchEvent($action, &$entity, $isNew = false, ?\Symfony\Contracts\EventDispatcher\Event $event = null): ?\Symfony\Contracts\EventDispatcher\Event
{
if ($entity instanceof CampaignLead) {
return null;
}
if (!$entity instanceof Campaign) {
throw new MethodNotAllowedHttpException(['Campaign']);
}
switch ($action) {
case 'pre_save':
$name = CampaignEvents::CAMPAIGN_PRE_SAVE;
break;
case 'post_save':
$name = CampaignEvents::CAMPAIGN_POST_SAVE;
break;
case 'pre_delete':
$name = CampaignEvents::CAMPAIGN_PRE_DELETE;
break;
case 'post_delete':
$name = CampaignEvents::CAMPAIGN_POST_DELETE;
break;
default:
return null;
}
if ($this->dispatcher->hasListeners($name)) {
if (empty($event)) {
$event = new Events\CampaignEvent($entity, $isNew);
}
$this->dispatcher->dispatch($event, $name);
return $event;
} else {
return null;
}
}
public function setEvents(Campaign $entity, $sessionEvents, $sessionConnections, $deletedEvents): array
{
$existingEvents = $entity->getEvents()->toArray();
$events = [];
$hierarchy = [];
foreach ($sessionEvents as $properties) {
$isNew = (!empty($properties['id']) && isset($existingEvents[$properties['id']])) ? false : true;
$event = !$isNew ? $existingEvents[$properties['id']] : new Event();
foreach ($properties as $f => $v) {
if ('id' == $f && str_starts_with($v, 'new')) {
// set the temp ID used to be able to match up connections
$event->setTempId($v);
}
if (in_array($f, ['id', 'parent', 'campaign'])) {
continue;
}
$func = 'set'.ucfirst($f);
if (method_exists($event, $func)) {
$event->$func($v);
}
}
ChannelExtractor::setChannel($event, $event, $this->eventCollector->getEventConfig($event));
$event->setCampaign($entity);
$events[$properties['id']] = $event;
}
foreach ($deletedEvents as $deleteMe) {
if (isset($existingEvents[$deleteMe])) {
// Remove child from parent
$parent = $existingEvents[$deleteMe]->getParent();
if ($parent) {
$parent->removeChild($existingEvents[$deleteMe]);
$existingEvents[$deleteMe]->removeParent();
}
$entity->removeEvent($existingEvents[$deleteMe]);
unset($events[$deleteMe]);
}
}
$relationships = [];
if (isset($sessionConnections['connections'])) {
foreach ($sessionConnections['connections'] as $connection) {
$source = $connection['sourceId'];
$target = $connection['targetId'];
if (in_array($source, ['lists', 'forms'])) {
// Only concerned with events and not sources
continue;
}
if (isset($connection['anchors']['source'])) {
$sourceDecision = $connection['anchors']['source'];
} else {
$sourceDecision = (!empty($connection['anchors'][0])) ? $connection['anchors'][0]['endpoint'] : null;
}
if ('leadsource' == $sourceDecision) {
// Lead source connection that does not matter
continue;
}
$relationships[$target] = [
'parent' => $source,
'decision' => $sourceDecision,
];
}
}
// Assign parent/child relationships
foreach ($events as $id => $e) {
if (isset($relationships[$id])) {
// Has a parent
$anchor = in_array($relationships[$id]['decision'], ['yes', 'no']) ? $relationships[$id]['decision'] : null;
$events[$id]->setDecisionPath($anchor);
$parentId = $relationships[$id]['parent'];
$events[$id]->setParent($events[$parentId]);
$hierarchy[$id] = $parentId;
} elseif ($events[$id]->getParent()) {
// No longer has a parent so null it out
// Remove decision so that it doesn't affect execution
$events[$id]->setDecisionPath(null);
// Remove child from parent
$parent = $events[$id]->getParent();
$parent->removeChild($events[$id]);
// Remove parent from child
$events[$id]->removeParent();
$hierarchy[$id] = 'null';
} else {
// Is a parent
$hierarchy[$id] = 'null';
// Remove decision so that it doesn't affect execution
$events[$id]->setDecisionPath(null);
}
}
$entity->addEvents($events);
// set event order used when querying the events
$this->buildOrder($hierarchy, $events, $entity);
uasort(
$events,
function ($a, $b): int {
$aOrder = $a->getOrder();
$bOrder = $b->getOrder();
return $aOrder <=> $bOrder;
}
);
// Persist events if campaign is being edited
if ($entity->getId()) {
$this->getEventRepository()->saveEntities($events);
}
return $events;
}
/**
* @param bool $persist
*
* @return array
*/
public function setCanvasSettings($entity, $settings, $persist = true, $events = null)
{
if (null === $events) {
$events = $entity->getEvents();
}
$tempIds = [];
foreach ($events as $e) {
if ($e instanceof Event) {
$tempIds[$e->getTempId()] = $e->getId();
} else {
$tempIds[$e['tempId']] = $e['id'];
}
}
if (!isset($settings['nodes'])) {
$settings['nodes'] = [];
}
foreach ($settings['nodes'] as &$node) {
if (str_contains($node['id'], 'new')) {
// Find the real one and update the node
$node['id'] = str_replace($node['id'], $tempIds[$node['id']], $node['id']);
}
}
if (!isset($settings['connections'])) {
$settings['connections'] = [];
}
foreach ($settings['connections'] as &$connection) {
// Check source
if (str_contains($connection['sourceId'], 'new')) {
// Find the real one and update the node
$connection['sourceId'] = str_replace($connection['sourceId'], $tempIds[$connection['sourceId']], $connection['sourceId']);
}
// Check target
if (str_contains($connection['targetId'], 'new')) {
// Find the real one and update the node
$connection['targetId'] = str_replace($connection['targetId'], $tempIds[$connection['targetId']], $connection['targetId']);
}
// Rebuild anchors
if (!isset($connection['anchors']['source'])) {
$anchors = [];
foreach ($connection['anchors'] as $k => $anchor) {
$type = (0 === $k) ? 'source' : 'target';
$anchors[$type] = $anchor['endpoint'];
}
$connection['anchors'] = $anchors;
}
}
$entity->setCanvasSettings($settings);
if ($persist) {
$this->getRepository()->saveEntity($entity);
}
return $settings;
}
/**
* Get list of sources for a campaign.
*/
public function getLeadSources($campaign): array
{
$campaignId = ($campaign instanceof Campaign) ? $campaign->getId() : $campaign;
$sources = [];
// Lead lists
$sources['lists'] = $this->getRepository()->getCampaignListSources($campaignId);
// Forms
$sources['forms'] = $this->getRepository()->getCampaignFormSources($campaignId);
return $sources;
}
/**
* Add and/or delete lead sources from a campaign.
*/
public function setLeadSources(Campaign $entity, $addedSources, $deletedSources): void
{
foreach ($addedSources as $type => $sources) {
foreach ($sources as $id => $label) {
switch ($type) {
case 'lists':
$entity->addList($this->em->getReference(\Mautic\LeadBundle\Entity\LeadList::class, $id));
break;
case 'forms':
$entity->addForm($this->em->getReference(Form::class, $id));
break;
default:
break;
}
}
}
foreach ($deletedSources as $type => $sources) {
foreach ($sources as $id => $label) {
switch ($type) {
case 'lists':
$entity->removeList($this->em->getReference(\Mautic\LeadBundle\Entity\LeadList::class, $id));
break;
case 'forms':
$entity->removeForm($this->em->getReference(Form::class, $id));
break;
default:
break;
}
}
}
}
/**
* Get a list of source choices.
*
* @param string $sourceType
* @param bool $globalOnly
*/
public function getSourceLists($sourceType = null, $globalOnly = false): array
{
$choices = [];
switch ($sourceType) {
case 'lists':
case null:
$choices['lists'] = [];
$lists = $globalOnly ? $this->leadListModel->getGlobalLists() : $this->leadListModel->getUserLists();
if ($lists) {
foreach ($lists as $list) {
$choices['lists'][$list['id']] = $list['name'];
}
}
// no break
case 'forms':
case null:
$choices['forms'] = [];
$viewOther = $this->security->isGranted('form:forms:viewother');
$repo = $this->formModel->getRepository();
$repo->setCurrentUser($this->userHelper->getUser());
$forms = $repo->getFormList('', 0, 0, $viewOther, 'campaign');
foreach ($forms as $form) {
$choices['forms'][$form['id']] = $form['name'];
}
}
foreach ($choices as &$typeChoices) {
asort($typeChoices);
}
return (null == $sourceType) ? $choices : $choices[$sourceType];
}
/**
* @param mixed $form
*
* @return array
*/
public function getCampaignsByForm($form)
{
$formId = ($form instanceof Form) ? $form->getId() : $form;
return $this->getRepository()->findByFormId($formId);
}
/**
* Gets the campaigns a specific lead is part of.
*
* @param bool $forList
*
* @return mixed
*/
public function getLeadCampaigns(?Lead $lead = null, $forList = false)
{
static $campaigns = [];
if (null === $lead) {
$lead = $this->contactTracker->getContact();
}
if (!isset($campaigns[$lead->getId()])) {
$repo = $this->getRepository();
$leadId = $lead->getId();
// get the campaigns the lead is currently part of
$campaigns[$leadId] = $repo->getPublishedCampaigns(
null,
$lead->getId(),
$forList,
$this->security->isGranted($this->getPermissionBase().':viewother')
);
}
return $campaigns[$lead->getId()];
}
/**
* Gets a list of published campaigns.
*
* @return array
*/
public function getPublishedCampaigns(bool $forList = false)
{
static $campaigns = [];
if (empty($campaigns)) {
$campaigns = $this->getRepository()->getPublishedCampaigns(
null,
null,
$forList,
$this->security->isGranted($this->getPermissionBase().':viewother')
);
}
return $campaigns;
}
/**
* Saves a campaign lead, logs the error if saving fails.
*/
public function saveCampaignLead(CampaignLead $campaignLead): bool
{
try {
$this->getCampaignLeadRepository()->saveEntity($campaignLead);
return true;
} catch (\Exception $exception) {
$this->logger->log('error', $exception->getMessage(), ['exception' => $exception]);
return false;
}
}
/**
* Get details of leads in a campaign.
*
* @return mixed
*/
public function getLeadDetails($campaign, $leads = null)
{
$campaignId = ($campaign instanceof Campaign) ? $campaign->getId() : $campaign;
if ($leads instanceof PersistentCollection) {
$leads = array_keys($leads->toArray());
}
return $this->em->getRepository(CampaignLead::class)->getLeadDetails($campaignId, $leads);
}
/**
* Get leads for a campaign. If $event is passed in, only leads who have not triggered the event are returned.
*
* @param Campaign $campaign
* @param array $event
*
* @return mixed
*/
public function getCampaignLeads($campaign, $event = null)
{
$campaignId = ($campaign instanceof Campaign) ? $campaign->getId() : $campaign;
$eventId = (is_array($event) && isset($event['id'])) ? $event['id'] : $event;
return $this->em->getRepository(CampaignLead::class)->getLeads($campaignId, $eventId);
}
public function getCampaignListIds($id): array
{
return $this->getRepository()->getCampaignListIds((int) $id);
}
/**
* Get line chart data of leads added to campaigns.
*
* @param string $unit {@link php.net/manual/en/function.date.php#refsect1-function.date-parameters}
* @param string $dateFormat
* @param array $filter
* @param bool $canViewOthers
*/
public function getLeadsAddedLineChartData($unit, \DateTime $dateFrom, \DateTime $dateTo, $dateFormat = null, $filter = [], $canViewOthers = true): array
{
$chart = new LineChart($unit, $dateFrom, $dateTo, $dateFormat);
$query = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);
$q = $query->prepareTimeDataQuery('campaign_leads', 'date_added', $filter);
if (!$canViewOthers) {
$q->join('t', MAUTIC_TABLE_PREFIX.'campaigns', 'c', 'c.id = c.campaign_id')
->andWhere('c.created_by = :userId')
->setParameter('userId', $this->userHelper->getUser()->getId());
}
$data = $query->loadAndBuildTimeData($q);
$chart->setDataset($this->translator->trans('mautic.campaign.campaign.leads'), $data);
return $chart->render();
}
/**
* Get line chart data of hits.
*
* @param string|null $unit {@link php.net/manual/en/function.date.php#refsect1-function.date-parameters}
* @param string $dateFormat
* @param array $filter
*/
public function getCampaignMetricsLineChartData($unit, \DateTime $dateFrom, \DateTime $dateTo, $dateFormat = null, $filter = []): array
{
$events = [];
$chart = new LineChart($unit, $dateFrom, $dateTo, $dateFormat);
$query = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);
$contacts = $query->fetchTimeData('campaign_leads', 'date_added', $filter);
$chart->setDataset($this->translator->trans('mautic.campaign.campaign.leads'), $contacts);
if (isset($filter['campaign_id'])) {
$rawEvents = $this->getEventRepository()->getCampaignEvents($filter['campaign_id']);
// Group events by type
foreach ($rawEvents as $event) {
if (isset($events[$event['type']])) {
$events[$event['type']][] = $event['id'];
} else {
$events[$event['type']] = [$event['id']];
}
}
if ($events) {
foreach ($events as $type => $eventIds) {
$filter['event_id'] = $eventIds;
if ($this->coreParametersHelper->get('campaign_use_summary')) {
$q = $query->prepareTimeDataQuery('campaign_summary', 'date_triggered', $filter, 'triggered_count + non_action_path_taken_count', 'sum');
$rawData = $q->executeQuery()->fetchAllAssociative();
} else {
// Exclude failed events
$failedSq = $this->em->getConnection()->createQueryBuilder();
$failedSq->select('null')
->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_failed_log', 'fe')
->where(
$failedSq->expr()->eq('fe.log_id', 't.id')
);
$filter['failed_events'] = [
'subquery' => sprintf('NOT EXISTS (%s)', $failedSq->getSQL()),
];
$q = $query->prepareTimeDataQuery('campaign_lead_event_log', 'date_triggered', $filter);
$rawData = $q->executeQuery()->fetchAllAssociative();
}
if (!empty($rawData)) {
$triggers = $query->completeTimeData($rawData);
$chart->setDataset($this->translator->trans('mautic.campaign.'.$type), $triggers);
}
}
unset($filter['event_id']);
}
}
return $chart->render();
}
/**
* @param Campaign $entity
* @param string $root
* @param int $order
*/
protected function buildOrder($hierarchy, &$events, $entity, $root = 'null', $order = 1)
{
$count = count($hierarchy);
if (1 === $count && 'null' === array_unique(array_values($hierarchy))[0]) {
// no parents so leave order as is
return;
} else {
foreach ($hierarchy as $eventId => $parent) {
if ($parent == $root || 1 === $count) {
$events[$eventId]->setOrder($order);
unset($hierarchy[$eventId]);
if (count($hierarchy)) {
$this->buildOrder($hierarchy, $events, $entity, $eventId, $order + 1);
}
}
}
}
}
/**
* @param int $limit
* @param bool $maxLeads
*/
public function rebuildCampaignLeads(Campaign $campaign, $limit = 1000, $maxLeads = false, ?OutputInterface $output = null): int
{
$contactLimiter = new ContactLimiter($limit);
return $this->membershipBuilder->build($campaign, $contactLimiter, $maxLeads, $output);
}
public function getCampaignIdsWithDependenciesOnSegment($segmentId): array
{
$entities = $this->getRepository()->getEntities(
[
'filter' => [
'force' => [
[
'column' => 'l.id',
'expr' => 'eq',
'value' => $segmentId,
],
],
],
'joinLists' => true,
]
);
$ids = [];
foreach ($entities as $entity) {
$ids[] = $entity->getId();
}
return $ids;
}
/**
* @return array<int, int>
*/
public function getCampaignIdsWithDependenciesOnEmail(int $emailId): array
{
return $this->getRepository()->getCampaignIdsWithDependenciesOnEmail($emailId);
}
/**
* @return array<int, int>
*/
public function getCampaignIdsWithDependenciesOnTagName(string $tagName): array
{
$entities = $this->getEventRepository()->getEntities(
[
'filter' => [
'force' => [
[
'column' => 'e.type',
'expr' => 'IN',
'value' => ['lead.changetags', 'lead.tags'],
],
],
],
]
);
$dependents = [];
/** @var Event $entity */
foreach ($entities as $entity) {
$type = $entity->getType();
$properties = $entity->getProperties();
if ('lead.changetags' === $type) {
$eventTags = array_merge([], $properties['add_tags'], $properties['remove_tags']);
}
if ('lead.tags' === $type) {
$eventTags = $properties['tags'];
}
if (in_array($tagName, $eventTags)) {
$dependents[] = $entity->getCampaign()->getId();
}
}
return array_unique($dependents);
}
/**
* @return array<string, array<int, array<string, int|string>>>
*
* @throws Exception
*/
public function getCountryStats(Campaign $entity, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): array
{
/** @var StatRepository $statRepo */
$statRepo = $this->em->getRepository(Stat::class);
$results['contacts'] = $this->getCampaignMembersGroupByCountry($entity, $dateFrom, $dateTo);
if ($entity->isEmailCampaign()) {
$eventsEmailsSend = $entity->getEmailSendEvents();
$eventsIds = $eventsEmailsSend->getKeys();
$emailIds = [];
foreach ($eventsEmailsSend as $event) {
$emailIds[] = $event->getChannelId();
}
$emailStats = $statRepo->getStatsSummaryByCountry($dateFrom, $dateTo, $emailIds, 'campaign', $eventsIds);
$results['read_count'] = $results['clicked_through_count'] = [];
foreach ($emailStats as $e) {
$results['read_count'][] = array_intersect_key($e, array_flip(['country', 'read_count']));
$results['clicked_through_count'][] = array_intersect_key($e, array_flip(['country', 'clicked_through_count']));
}
}
return $results;
}
/**
* Get leads in a campaign grouped by country.
*
* @return array{}|array<int, array<string, string|null>>
*/
public function getCampaignMembersGroupByCountry(Campaign $campaign, \DateTimeImmutable $dateFromObject, \DateTimeImmutable $dateToObject): array
{
return $this->em->getRepository(CampaignLead::class)->getCampaignMembersGroupByCountry($campaign, $dateFromObject, $dateToObject);
}
/**
* @throws CampaignAlreadyUnpublishedException
* @throws CampaignVersionMismatchedException
* @throws Exception
*/
public function transactionalCampaignUnPublish(Campaign $campaign): void
{
$this->em->beginTransaction();
$result = $this->getRepository()->getCampaignPublishAndVersionData($campaign->getId());
if (!(int) $result['is_published']) {
$this->em->commit();
throw new CampaignAlreadyUnpublishedException('Campaign is unpublished!');
}
if ((int) $result['version'] !== $campaign->getVersion()) {
$this->em->commit();
throw new CampaignVersionMismatchedException('Version do not match!');
}
$campaign->setIsPublished(false);
$campaign->markForVersionIncrement();
$this->saveEntity($campaign);
$this->em->commit();
}
}

View File

@@ -0,0 +1,215 @@
<?php
namespace Mautic\CampaignBundle\Model;
use Doctrine\ORM\EntityManager;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\Entity\LeadEventLogRepository;
use Mautic\CampaignBundle\Executioner\Scheduler\EventScheduler;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\AbstractCommonModel;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Entity\Lead;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* @extends AbstractCommonModel<LeadEventLog>
*/
class EventLogModel extends AbstractCommonModel
{
public function __construct(
protected EventModel $eventModel,
protected CampaignModel $campaignModel,
protected IpLookupHelper $ipLookupHelper,
protected EventScheduler $eventScheduler,
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(): LeadEventLogRepository
{
return $this->em->getRepository(LeadEventLog::class);
}
public function getPermissionBase(): string
{
return 'campaign:campaigns';
}
public function getEntities(array $args = [])
{
/** @var LeadEventLog[] $logs */
$logs = parent::getEntities($args);
if (!empty($args['campaign_id']) && !empty($args['contact_id'])) {
/** @var Event[] $events */
$events = $this->eventModel->getEntities(
[
'campaign_id' => $args['campaign_id'],
'ignore_children' => true,
'index_by' => 'id',
'ignore_paginator' => true,
]
);
foreach ($logs as $log) {
$event = $log->getEvent()->getId();
$events[$event]->addContactLog($log);
}
return array_values($events);
}
return $logs;
}
/**
* @return string|mixed[]
*/
public function updateContactEvent(Event $event, Lead $contact, array $parameters): string|array
{
$campaign = $event->getCampaign();
// Check that contact is part of the campaign
$membership = $campaign->getContactMembership($contact);
if (0 === count($membership)) {
return $this->translator->trans(
'mautic.campaign.error.contact_not_in_campaign',
['%campaign%' => $campaign->getId(), '%contact%' => $contact->getId()],
'flashes'
);
}
/** @var \Mautic\CampaignBundle\Entity\Lead $m */
foreach ($membership as $m) {
if ($m->getManuallyRemoved()) {
return $this->translator->trans(
'mautic.campaign.error.contact_not_in_campaign',
['%campaign%' => $campaign->getId(), '%contact%' => $contact->getId()],
'flashes'
);
}
}
// Check that contact has not executed the event already
$logs = $event->getContactLog($contact);
$created = false;
if (count($logs)) {
$log = $logs[0];
if ($log->getDateTriggered()) {
return $this->translator->trans(
'mautic.campaign.error.event_already_executed',
[
'%campaign%' => $campaign->getId(),
'%event%' => $event->getId(),
'%contact%' => $contact->getId(),
'%dateTriggered%' => $log->getDateTriggered()->format(\DateTimeInterface::ATOM),
],
'flashes'
);
}
} else {
if (!isset($parameters['triggerDate']) && !isset($parameters['dateTriggered'])) {
return $this->translator->trans(
'mautic.campaign.error.event_must_be_scheduled',
[
'%campaign%' => $campaign->getId(),
'%event%' => $event->getId(),
'%contact%' => $contact->getId(),
],
'flashes'
);
}
$log = (new LeadEventLog())
->setLead($contact)
->setEvent($event);
$created = true;
}
foreach ($parameters as $property => $value) {
switch ($property) {
case 'dateTriggered':
$log->setDateTriggered(
new \DateTime($value)
);
break;
case 'triggerDate':
if (Event::TYPE_DECISION === $event->getEventType()) {
return $this->translator->trans(
'mautic.campaign.error.decision_cannot_be_scheduled',
[
'%campaign%' => $campaign->getId(),
'%event%' => $event->getId(),
'%contact%' => $contact->getId(),
],
'flashes'
);
}
$log->setTriggerDate(
new \DateTime($value)
);
break;
case 'ipAddress':
if (!defined('MAUTIC_CAMPAIGN_SYSTEM_TRIGGERED')) {
$log->setIpAddress(
$this->ipLookupHelper->getIpAddress($value)
);
}
break;
case 'metadata':
$metadata = $log->getMetadata();
if (is_array($value)) {
$newMetadata = $value;
} elseif ($jsonDecoded = json_decode($value, true)) {
$newMetadata = $jsonDecoded;
} else {
$newMetadata = (array) $value;
}
$newMetadata = InputHelper::cleanArray($newMetadata);
$log->setMetadata(array_merge($metadata, $newMetadata));
break;
case 'nonActionPathTaken':
$log->setNonActionPathTaken((bool) $value);
break;
case 'channel':
$log->setChannel(InputHelper::clean($value));
break;
case 'channelId':
$log->setChannel(intval($value));
break;
}
}
$this->saveEntity($log);
return [$log, $created];
}
public function saveEntity(LeadEventLog $entity): void
{
$triggerDate = $entity->getTriggerDate();
if (null === $triggerDate) {
// Reschedule for now
$triggerDate = new \DateTime();
}
$this->eventScheduler->reschedule($entity, $triggerDate);
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace Mautic\CampaignBundle\Model;
use Mautic\CampaignBundle\CampaignEvents;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\Entity\LeadEventLogRepository;
use Mautic\CampaignBundle\Event\DeleteEvent;
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
use Mautic\CoreBundle\Helper\Chart\LineChart;
use Mautic\CoreBundle\Model\FormModel;
/**
* @extends FormModel<Event>
*/
class EventModel extends FormModel
{
/**
* @return \Mautic\CampaignBundle\Entity\EventRepository
*/
public function getRepository()
{
return $this->em->getRepository(Event::class);
}
/**
* @return \Mautic\CampaignBundle\Entity\CampaignRepository
*/
public function getCampaignRepository()
{
return $this->em->getRepository(Campaign::class);
}
/**
* @return LeadEventLogRepository
*/
public function getLeadEventLogRepository()
{
return $this->em->getRepository(LeadEventLog::class);
}
public function getPermissionBase(): string
{
return 'campaign:campaigns';
}
/**
* Get a specific entity or generate a new one if id is empty.
*/
public function getEntity($id = null): ?Event
{
if (null === $id) {
return new Event();
}
return parent::getEntity($id);
}
public function deleteEvents($currentEvents, $deletedEvents): void
{
$deletedKeys = [];
foreach ($deletedEvents as $k => $deleteMe) {
if ($deleteMe instanceof Event) {
$deleteMe = $deleteMe->getId();
}
if (str_starts_with($deleteMe, 'new')) {
unset($deletedEvents[$k]);
}
if (isset($currentEvents[$deleteMe])) {
unset($deletedEvents[$k]);
}
if (isset($deletedEvents[$k])) {
$deletedKeys[] = $deleteMe;
}
}
if (count($deletedEvents)) {
// wipe out any references to these events to prevent restraint violations
$this->getRepository()->nullEventRelationships($deletedKeys);
$this->getRepository()->setEventsAsDeleted($deletedEvents);
$this->dispatcher->dispatch(new DeleteEvent($deletedKeys), CampaignEvents::ON_EVENT_DELETE);
}
}
public function deleteEventsByCampaignId(int $campaignId): void
{
$eventIds = $this->getRepository()->getCampaignEventIds($campaignId);
$this->deleteEventsByEventIds($eventIds);
}
/**
* @param string[] $eventIds
*/
public function deleteEventsByEventIds(array $eventIds): void
{
$this->getRepository()->deleteEvents($eventIds);
$this->dispatcher->dispatch(new DeleteEvent($eventIds), CampaignEvents::ON_AFTER_EVENTS_DELETE);
}
/**
* Get line chart data of campaign events.
*
* @param string $unit {@link php.net/manual/en/function.date.php#refsect1-function.date-parameters}
* @param string $dateFormat
* @param array $filter
* @param bool $canViewOthers
*/
public function getEventLineChartData($unit, \DateTime $dateFrom, \DateTime $dateTo, $dateFormat = null, $filter = [], $canViewOthers = true): array
{
$chart = new LineChart($unit, $dateFrom, $dateTo, $dateFormat);
$query = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);
$q = $query->prepareTimeDataQuery('campaign_lead_event_log', 'date_triggered', $filter);
if (!$canViewOthers) {
$q->join('t', MAUTIC_TABLE_PREFIX.'campaigns', 'c', 'c.id = t.campaign_id')
->andWhere('c.created_by = :userId')
->setParameter('userId', $this->userHelper->getUser()->getId());
}
$data = $query->loadAndBuildTimeData($q);
$chart->setDataset($this->translator->trans('mautic.campaign.triggered.events'), $data);
return $chart->render();
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Model\Exceptions;
class CampaignAlreadyUnpublishedException extends \Exception
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Model\Exceptions;
class CampaignVersionMismatchedException extends \Exception
{
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Model;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\Entity\LeadEventLogRepository;
use Mautic\CampaignBundle\Entity\Summary;
use Mautic\CampaignBundle\Entity\SummaryRepository;
use Mautic\CoreBundle\Helper\ProgressBarHelper;
use Mautic\CoreBundle\Model\AbstractCommonModel;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @extends AbstractCommonModel<Summary>
*/
class SummaryModel extends AbstractCommonModel
{
private array $logData = [];
/**
* Collapse Event Log entities into insert/update queries for the campaign summary.
*
* @throws \Doctrine\DBAL\Exception
*/
public function updateSummary(iterable $logs): void
{
$now = new \DateTime();
/** @var LeadEventLog $log */
foreach ($logs as $log) {
if (!$log->getDateTriggered()) {
// This shouldn't normally happen but it's possible to have a log without a date triggered
// as it is a nullable field and it can be created without date triggered for example via API.
continue;
}
$timestamp = $log->getDateTriggered()->getTimestamp();
$timestamp -= ($timestamp % 3600);
$dateFrom = $now->setTimestamp($timestamp);
$dateTo = (clone $dateFrom)->modify('+1 hour -1 second');
$campaign = $log->getCampaign();
$event = $log->getEvent();
$key = $campaign->getId().'.'.$event->getId().'.'.$timestamp;
$this->logData[$key] = [
'campaignId' => $campaign->getId(),
'eventId' => $event->getId(),
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
];
}
if (count($this->logData) >= 100) {
$this->persistSummaries();
}
}
public function getRepository(): SummaryRepository
{
return $this->em->getRepository(Summary::class);
}
public function getPermissionBase(): string
{
return 'campaign:campaigns';
}
/**
* Summarize all of history.
*
* @throws \Doctrine\DBAL\Exception
*/
public function summarize(OutputInterface $output, int $hoursPerBatch = 1, ?int $maxHours = null, bool $rebuild = false): void
{
$start = null;
if (!$rebuild) {
$start = $this->getRepository()->getOldestTriggeredDate();
}
// Start with the current hour.
$start ??= new \DateTime('+1 hour');
$start->setTimestamp($start->getTimestamp() - ($start->getTimestamp() % 3600));
$end = $this->getCampaignLeadEventLogRepository()->getOldestTriggeredDate();
if (!$end) {
$output->writeln('There are no records in the campaign lead event log table. Nothing to summarize.');
return;
}
$end = $end->setTimestamp($end->getTimestamp() - ($end->getTimestamp() % 3600));
$startedAt = new \DateTime();
$output->writeln('<comment>Started at: '.$startedAt->format('Y-m-d H:i:s').'</comment>');
if ($end <= $start) {
$hours = ($end->diff($start)->days * 24) + $end->diff($start)->h;
if ($maxHours && $hours > $maxHours) {
$end = clone $start;
$end = $end->sub(new \DateInterval('PT'.$maxHours.'H'));
}
$progressBar = ProgressBarHelper::init($output, $hours);
$progressBar->start();
$interval = new \DateInterval('PT'.$hoursPerBatch.'H');
$dateFrom = clone $start;
$dateTo = (clone $start)->modify('-1 second');
do {
$dateFrom = $dateFrom->sub($interval);
$dateFromFormatted = $dateFrom->format('Y-m-d H:i:s');
$dateToFormatted = $dateTo->format('Y-m-d H:i:s');
$output->write("\t".$dateFromFormatted.' - '.$dateToFormatted);
$this->getRepository()->summarize($dateFrom, $dateTo);
$progressBar->advance($hoursPerBatch);
$dateTo = $dateTo->sub($interval);
} while ($end < $dateFrom);
$progressBar->finish();
$output->writeln("\n".'<info>Updating summary for log counts processed</info>');
}
$this->outputProcessTime($startedAt, $output);
}
public function getCampaignLeadEventLogRepository(): LeadEventLogRepository
{
return $this->em->getRepository(LeadEventLog::class);
}
/**
* @throws \Doctrine\DBAL\Exception
*/
public function persistSummaries(): void
{
foreach ($this->logData as $log) {
$dateFrom = $log['dateFrom'];
$dateTo = $log['dateTo'];
$campaignId = $log['campaignId'];
$eventId = $log['eventId'];
$this->getRepository()->summarize($dateFrom, $dateTo, $campaignId, $eventId);
}
}
private function outputProcessTime(\DateTime $startedAt, OutputInterface $output): void
{
$endedAt = new \DateTime();
$output->writeln("\n".'<comment>Ended at: '.$endedAt->format('Y-m-d H:i:s').'</comment>');
$completedInterval = $startedAt->diff($endedAt);
$output->writeln('<info>Summary completed in: '.$completedInterval->format('%H:%I:%S').'</info>');
}
}