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,81 @@
<?php
namespace Mautic\CampaignBundle\Membership\Action;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Lead as CampaignMember;
use Mautic\CampaignBundle\Entity\LeadEventLogRepository;
use Mautic\CampaignBundle\Entity\LeadRepository;
use Mautic\CampaignBundle\Membership\Exception\ContactCannotBeAddedToCampaignException;
use Mautic\LeadBundle\Entity\Lead;
class Adder
{
public const NAME = 'added';
public function __construct(
private LeadRepository $leadRepository,
private LeadEventLogRepository $leadEventLogRepository,
) {
}
public function createNewMembership(Lead $contact, Campaign $campaign, $isManualAction): CampaignMember
{
// BC support for prior to 2.14.
// If the contact was in the campaign to start with then removed, their logs remained but the original membership was removed
// Start the new rotation at 2
$rotation = 1;
if ($this->leadEventLogRepository->hasBeenInCampaignRotation($contact->getId(), $campaign->getId(), 1)) {
$rotation = 2;
}
$campaignMember = new CampaignMember();
$campaignMember->setLead($contact);
$campaignMember->setCampaign($campaign);
$campaignMember->setManuallyAdded($isManualAction);
$campaignMember->setDateAdded(new \DateTime());
$campaignMember->setRotation($rotation);
$this->saveCampaignMember($campaignMember);
return $campaignMember;
}
/**
* @param bool $isManualAction
*
* @throws ContactCannotBeAddedToCampaignException
*/
public function updateExistingMembership(CampaignMember $campaignMember, $isManualAction): void
{
$wasRemoved = $campaignMember->wasManuallyRemoved();
if (!($wasRemoved && $isManualAction) && !$campaignMember->getCampaign()->allowRestart()) {
// A contact cannot restart this campaign
throw new ContactCannotBeAddedToCampaignException('Contacts cannot restart the campaign');
}
if ($wasRemoved && !$isManualAction && null === $campaignMember->getDateLastExited()) {
// Prevent contacts from being added back if they were manually removed but automatically added back
throw new ContactCannotBeAddedToCampaignException('Contact was manually removed');
}
if ($wasRemoved && $isManualAction) {
// If they were manually removed and manually added back, mark it as so
$campaignMember->setManuallyAdded($isManualAction);
}
// Contact exited but has been added back to the campaign
$campaignMember->setManuallyRemoved(false);
$campaignMember->setDateLastExited(null);
$campaignMember->startNewRotation();
$this->saveCampaignMember($campaignMember);
}
private function saveCampaignMember($campaignMember): void
{
$this->leadRepository->saveEntity($campaignMember);
$this->leadRepository->detachEntity($campaignMember);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Mautic\CampaignBundle\Membership\Action;
use Mautic\CampaignBundle\Entity\Lead as CampaignMember;
use Mautic\CampaignBundle\Entity\LeadEventLogRepository;
use Mautic\CampaignBundle\Entity\LeadRepository;
use Mautic\CampaignBundle\Membership\Exception\ContactAlreadyRemovedFromCampaignException;
use Mautic\CoreBundle\Twig\Helper\DateHelper;
use Symfony\Contracts\Translation\TranslatorInterface;
class Remover
{
public const NAME = 'removed';
private string $unscheduledMessage;
public function __construct(
private LeadRepository $leadRepository,
private LeadEventLogRepository $leadEventLogRepository,
TranslatorInterface $translator,
DateHelper $dateHelper,
) {
$dateRemoved = $dateHelper->toFull(new \DateTime());
$this->unscheduledMessage = $translator->trans('mautic.campaign.member.removed', ['%date%' => $dateRemoved]);
}
/**
* @param bool $isExit
*
* @throws ContactAlreadyRemovedFromCampaignException
*/
public function updateExistingMembership(CampaignMember $campaignMember, $isExit): void
{
if ($isExit) {
// Contact was removed by the change campaign action or a segment
$campaignMember->setDateLastExited(new \DateTime());
} else {
$campaignMember->setDateLastExited(null);
}
if ($campaignMember->wasManuallyRemoved()) {
$this->saveCampaignMember($campaignMember);
// Contact was already removed from this campaign
throw new ContactAlreadyRemovedFromCampaignException();
}
// Unschedule any scheduled events
$this->leadEventLogRepository->unscheduleEvents($campaignMember, $this->unscheduledMessage);
// Remove this contact from the campaign
$campaignMember->setManuallyRemoved(true);
$campaignMember->setManuallyAdded(false);
$this->saveCampaignMember($campaignMember);
}
private function saveCampaignMember($campaignMember): void
{
$this->leadRepository->saveEntity($campaignMember);
$this->leadRepository->detachEntity($campaignMember);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Mautic\CampaignBundle\Membership;
use Mautic\CampaignBundle\CampaignEvents;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Event\CampaignLeadChangeEvent;
use Mautic\LeadBundle\Entity\Lead;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class EventDispatcher
{
public function __construct(
private EventDispatcherInterface $dispatcher,
) {
}
/**
* @param string $action
*/
public function dispatchMembershipChange(Lead $contact, Campaign $campaign, $action): void
{
$this->dispatcher->dispatch(
new CampaignLeadChangeEvent($campaign, $contact, $action),
CampaignEvents::CAMPAIGN_ON_LEADCHANGE
);
}
public function dispatchBatchMembershipChange(array $contacts, Campaign $campaign, $action): void
{
$this->dispatcher->dispatch(
new CampaignLeadChangeEvent($campaign, $contacts, $action),
CampaignEvents::LEAD_CAMPAIGN_BATCH_CHANGE
);
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Mautic\CampaignBundle\Membership\Exception;
use Mautic\CoreBundle\Exception\FlattenableException;
class ContactAlreadyRemovedFromCampaignException extends FlattenableException
{
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Mautic\CampaignBundle\Membership\Exception;
use Mautic\CoreBundle\Exception\FlattenableException;
class ContactCannotBeAddedToCampaignException extends FlattenableException
{
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Mautic\CampaignBundle\Membership\Exception;
class RunLimitReachedException extends \Exception
{
private int $contactsProcessed;
public function __construct($contactsProcessed)
{
$this->contactsProcessed = (int) $contactsProcessed;
parent::__construct();
}
public function getContactsProcessed(): int
{
return $this->contactsProcessed;
}
}

View File

@@ -0,0 +1,214 @@
<?php
namespace Mautic\CampaignBundle\Membership;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\LeadRepository as CampaignLeadRepository;
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
use Mautic\CampaignBundle\Membership\Exception\RunLimitReachedException;
use Mautic\CoreBundle\Helper\ProgressBarHelper;
use Mautic\LeadBundle\Entity\LeadRepository;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class MembershipBuilder
{
private ?Campaign $campaign = null;
private ?ContactLimiter $contactLimiter = null;
private ?int $runLimit = null;
private ?OutputInterface $output = null;
private ?\Symfony\Component\Console\Helper\ProgressBar $progressBar = null;
public function __construct(
private MembershipManager $manager,
private CampaignLeadRepository $campaignLeadRepository,
private LeadRepository $leadRepository,
private TranslatorInterface $translator,
) {
}
/**
* @param int $runLimit
*/
public function build(Campaign $campaign, ContactLimiter $contactLimiter, $runLimit, ?OutputInterface $output = null): int
{
defined('MAUTIC_REBUILDING_CAMPAIGNS') or define('MAUTIC_REBUILDING_CAMPAIGNS', 1);
$this->campaign = $campaign;
$this->contactLimiter = $contactLimiter;
$this->runLimit = (int) $runLimit;
$this->output = $output;
$contactsProcessed = 0;
try {
$contactsProcessed += $this->addNewlyQualifiedMembers($contactsProcessed);
} catch (RunLimitReachedException $exception) {
return $exception->getContactsProcessed();
}
try {
$contactsProcessed += $this->removeUnqualifiedMembers($contactsProcessed);
} catch (RunLimitReachedException $exception) {
return $exception->getContactsProcessed();
}
return $contactsProcessed;
}
/**
* Add contacts to a campaign.
*
* @throws RunLimitReachedException
*/
private function addNewlyQualifiedMembers(int $totalContactsProcessed): int
{
$contactsProcessed = 0;
if ($this->output) {
$countResult = $this->campaignLeadRepository->getCountsForCampaignContactsBySegment(
$this->campaign->getId(),
$this->contactLimiter
);
$this->output->writeln(
$this->translator->trans(
'mautic.campaign.rebuild.to_be_added',
['%leads%' => $countResult->getCount(), '%batch%' => $this->contactLimiter->getBatchLimit()]
)
);
if (0 === $countResult->getCount()) {
// No use continuing
return 0;
}
$this->startProgressBar($countResult->getCount());
}
$contacts = $this->campaignLeadRepository->getCampaignContactsBySegments(
$this->campaign->getId(),
$this->contactLimiter,
$this->campaign->allowRestart()
);
while (count($contacts)) {
// get an array of contact entities based on the contact id
$contactCollection = $this->leadRepository->getContactCollection($contacts);
if ($contactCollection->count() <= 0) {
// Prevent endless loop just in case
break;
}
// increase the total nr of contacts processed by this batch
$contactsProcessed += $contactCollection->count();
// Add the contacts to this segment
$this->manager->addContacts($contactCollection, $this->campaign, false);
// Clear Lead entities from RAM
$this->leadRepository->detachEntities($contactCollection->toArray());
// Have we hit the run limit?
if ($this->runLimit && $contactsProcessed >= $this->runLimit) {
$this->finishProgressBar();
throw new RunLimitReachedException($contactsProcessed + $totalContactsProcessed);
}
// Get next batch
$contacts = $this->campaignLeadRepository->getCampaignContactsBySegments(
$this->campaign->getId(),
$this->contactLimiter,
$this->campaign->allowRestart()
);
}
$this->finishProgressBar();
return $contactsProcessed;
}
/**
* @throws RunLimitReachedException
*/
private function removeUnqualifiedMembers(int $totalContactsProcessed): int
{
$contactsProcessed = 0;
if ($this->output) {
$countResult = $this->campaignLeadRepository->getCountsForOrphanedContactsBySegments($this->campaign->getId(), $this->contactLimiter);
$this->output->writeln(
$this->translator->trans(
'mautic.lead.list.rebuild.to_be_removed',
['%leads%' => $countResult->getCount(), '%batch%' => $this->contactLimiter->getBatchLimit()]
)
);
if (0 === $countResult->getCount()) {
// No use continuing
return 0;
}
$this->startProgressBar($countResult->getCount());
}
$contacts = $this->campaignLeadRepository->getOrphanedContacts($this->campaign->getId(), $this->contactLimiter);
while (count($contacts)) {
$contactCollection = $this->leadRepository->getContactCollection($contacts);
if (!$contactCollection->count()) {
// Prevent endless loop just in case
break;
}
$contactsProcessed += $contactCollection->count();
// Add the contacts to this segment
$this->manager->removeContacts($contactCollection, $this->campaign, true);
// Clear Lead entities from RAM
$this->leadRepository->detachEntities($contactCollection->toArray());
// Have we hit the run limit?
if ($this->runLimit && $contactsProcessed >= $this->runLimit) {
$this->finishProgressBar();
throw new RunLimitReachedException($contactsProcessed + $totalContactsProcessed);
}
// Get next batch
$contacts = $this->campaignLeadRepository->getOrphanedContacts($this->campaign->getId(), $this->contactLimiter);
}
$this->finishProgressBar();
return $contactsProcessed;
}
private function startProgressBar(int $total): void
{
if (!$this->output) {
$this->progressBar = null;
$this->manager->setProgressBar($this->progressBar);
return;
}
$this->progressBar = ProgressBarHelper::init($this->output, $total);
$this->progressBar->start();
// Notify the manager to increment progress as contacts are added
$this->manager->setProgressBar($this->progressBar);
}
private function finishProgressBar(): void
{
if ($this->progressBar) {
$this->progressBar->finish();
$this->output->writeln('');
}
}
}

View File

@@ -0,0 +1,232 @@
<?php
namespace Mautic\CampaignBundle\Membership;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Lead as CampaignMember;
use Mautic\CampaignBundle\Entity\LeadRepository;
use Mautic\CampaignBundle\Membership\Action\Adder;
use Mautic\CampaignBundle\Membership\Action\Remover;
use Mautic\CampaignBundle\Membership\Exception\ContactAlreadyRemovedFromCampaignException;
use Mautic\CampaignBundle\Membership\Exception\ContactCannotBeAddedToCampaignException;
use Mautic\LeadBundle\Entity\Lead;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Helper\ProgressBar;
class MembershipManager
{
public const ACTION_ADDED = 'added';
public const ACTION_REMOVED = 'removed';
private ?ProgressBar $progressBar = null;
public function __construct(
private Adder $adder,
private Remover $remover,
private EventDispatcher $eventDispatcher,
private LeadRepository $leadRepository,
private LoggerInterface $logger,
) {
}
/**
* @param bool $isManualAction
*/
public function addContact(Lead $contact, Campaign $campaign, $isManualAction = true): void
{
// Validate that contact is not already in the Campaign
/** @var CampaignMember $campaignMember */
$campaignMember = $this->leadRepository->findOneBy(
[
'lead' => $contact,
'campaign' => $campaign,
]
);
if ($campaignMember) {
try {
$this->adder->updateExistingMembership($campaignMember, $isManualAction);
$this->logger->debug(
"CAMPAIGN: Membership for contact ID {$contact->getId()} in campaign ID {$campaign->getId()} was updated to be included."
);
// Notify listeners
$this->eventDispatcher->dispatchMembershipChange($campaignMember->getLead(), $campaignMember->getCampaign(), Adder::NAME);
} catch (ContactCannotBeAddedToCampaignException $exception) {
// Do nothing
$this->logger->debug(
"CAMPAIGN: Contact ID {$contact->getId()} could not be added to campaign ID {$campaign->getId()}.",
$exception->toArray()
);
}
return;
}
try {
// Contact is not already in the campaign so create a new entry
$this->adder->createNewMembership($contact, $campaign, $isManualAction);
} catch (ContactCannotBeAddedToCampaignException $exception) {
// Do nothing
$this->logger->debug(
"CAMPAIGN: Contact ID {$contact->getId()} could not be added to campaign ID {$campaign->getId()}.",
$exception->toArray()
);
return;
}
$this->logger->debug("CAMPAIGN: Contact ID {$contact->getId()} was added to campaign ID {$campaign->getId()} as a new member.");
// Notify listeners the contact has been added
$this->eventDispatcher->dispatchMembershipChange($contact, $campaign, Adder::NAME);
}
/**
* @param ArrayCollection<int, Lead> $contacts
* @param bool $isManualAction
*/
public function addContacts(ArrayCollection $contacts, Campaign $campaign, $isManualAction = true): void
{
// Get a list of existing campaign members
$campaignMembers = $this->leadRepository->getCampaignMembers($contacts->getKeys(), $campaign);
foreach ($contacts as $contact) {
$this->advanceProgressBar();
$this->logger->debug(
'CAMPAIGN: Contacts: '.count($contacts),
array_map(fn ($item) => $item->getId(), $contacts->toArray())
);
// is the contact an existing campaign member? update and continue
if (isset($campaignMembers[$contact->getId()])) {
try {
$this->adder->updateExistingMembership($campaignMembers[$contact->getId()], $isManualAction);
$this->logger->debug(
"CAMPAIGN: Membership for contact ID {$contact->getId()} in campaign ID {$campaign->getId()} was updated to be included."
);
} catch (ContactCannotBeAddedToCampaignException $exception) {
$contacts->remove($contact->getId());
$this->logger->debug(
"CAMPAIGN: Contact ID {$contact->getId()} could not be added to campaign ID {$campaign->getId()}.",
$exception->toArray()
);
}
continue;
}
// Existing membership does not exist so create a new one
$this->adder->createNewMembership($contact, $campaign, $isManualAction);
$this->logger->debug("CAMPAIGN: Contact ID {$contact->getId()} was added to campaign ID {$campaign->getId()} as a new member.");
}
if ($contacts->count()) {
// Notifiy listeners
$this->eventDispatcher->dispatchBatchMembershipChange($contacts->toArray(), $campaign, Adder::NAME);
}
// Clear entities from RAM
$this->leadRepository->detachEntities($campaignMembers);
}
/**
* @param bool $isExit
*/
public function removeContact(Lead $contact, Campaign $campaign, $isExit = false): void
{
// Validate that contact is not already in the Campaign
/** @var CampaignMember $campaignMember */
$campaignMember = $this->leadRepository->findOneBy(
[
'lead' => $contact,
'campaign' => $campaign,
]
);
if (!$campaignMember) {
// Contact is not in this campaign
$this->logger->debug("CAMPAIGN: Contact ID {$contact->getId()} is not in campaign ID {$campaign->getId()}.");
return;
}
try {
$this->remover->updateExistingMembership($campaignMember, $isExit);
$this->logger->debug("CAMPAIGN: Contact ID {$contact->getId()} was removed from campaign ID {$campaign->getId()}.");
// Notify listeners
$this->eventDispatcher->dispatchMembershipChange($contact, $campaign, Remover::NAME);
} catch (ContactAlreadyRemovedFromCampaignException $exception) {
// Do nothing
$this->logger->debug(
"CAMPAIGN: Contact ID {$contact->getId()} was already removed from campaign ID {$campaign->getId()}.",
$exception->toArray()
);
}
}
/**
* @param ArrayCollection<int, Lead> $contacts
* @param bool $isExit If true, the contact can be added by a segment/source. If false, the contact can only be added back
* by a manual process.
*/
public function removeContacts(ArrayCollection $contacts, Campaign $campaign, $isExit = false): void
{
// Get a list of existing campaign members
$campaignMembers = $this->leadRepository->getCampaignMembers($contacts->getKeys(), $campaign);
foreach ($contacts as $contact) {
$this->advanceProgressBar();
if (!isset($campaignMembers[$contact->getId()])) {
// Contact is not in the campaign
$contacts->remove($contact->getId());
continue;
}
/** @var CampaignMember $campaignMember */
$campaignMember = $campaignMembers[$contact->getId()];
try {
$this->remover->updateExistingMembership($campaignMember, $isExit);
$this->logger->debug("CAMPAIGN: Contact ID {$contact->getId()} was removed from campaign ID {$campaign->getId()}.");
} catch (ContactAlreadyRemovedFromCampaignException $exception) {
// Contact was already removed from this campaign
$contacts->remove($contact->getId());
$this->logger->debug(
"CAMPAIGN: Contact ID {$contact->getId()} was already removed from campaign ID {$campaign->getId()}.",
$exception->toArray()
);
}
}
if ($contacts->count()) {
// Notify listeners
$this->eventDispatcher->dispatchBatchMembershipChange($contacts->toArray(), $campaign, Remover::NAME);
}
// Clear entities from RAM
$this->leadRepository->detachEntities($campaignMembers);
}
public function setProgressBar(?ProgressBar $progressBar = null): void
{
$this->progressBar = $progressBar;
}
private function advanceProgressBar(): void
{
if ($this->progressBar) {
$this->progressBar->advance();
}
}
}