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,84 @@
<?php
namespace Mautic\ReportBundle\Model;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\CsvHelper;
use Mautic\CoreBundle\Twig\Helper\FormatterHelper;
use Mautic\ReportBundle\Crate\ReportDataResult;
use Symfony\Contracts\Translation\TranslatorInterface;
class CsvExporter
{
public function __construct(
protected FormatterHelper $formatterHelper,
private CoreParametersHelper $coreParametersHelper,
private TranslatorInterface $translator,
) {
}
/**
* @param resource $handle
* @param int $page
*/
public function export(ReportDataResult $reportDataResult, $handle, $page = 1): void
{
if (1 === $page) {
$this->putHeader($reportDataResult, $handle);
}
foreach ($reportDataResult->getData() as $data) {
$row = [];
foreach ($data as $k => $v) {
$type = $reportDataResult->getType($k);
$typeString = 'string' !== $type;
$row[] = $typeString ? $this->formatterHelper->_($v, $type, true) : $v;
}
$this->putRow($handle, $row);
}
if ($reportDataResult->isLastPage()) {
$totalsRow = $reportDataResult->getTotalsToExport($this->formatterHelper);
if (!empty($totalsRow)) {
$this->putTotals($totalsRow, $handle);
}
}
}
/**
* @param resource $handle
*/
public function putHeader(ReportDataResult $reportDataResult, $handle): void
{
$this->putRow($handle, $reportDataResult->getHeaders());
}
/**
* @param array<string> $totals
* @param resource $handle
*/
public function putTotals(array $totals, $handle): void
{
// Put label if the first item is empty
$key = array_key_first($totals);
if (empty($totals[$key])) {
$totals[$key] = $this->translator->trans('mautic.report.report.groupby.totals');
}
$this->putRow($handle, $totals);
}
/**
* @param resource $handle
*/
private function putRow($handle, array $row): void
{
if ($this->coreParametersHelper->get('csv_always_enclose')) {
fputs($handle, '"'.implode('","', $row).'"'."\n");
} else {
CsvHelper::putCsv($handle, $row);
}
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace Mautic\ReportBundle\Model;
use Mautic\CoreBundle\Twig\Helper\FormatterHelper;
use Mautic\ReportBundle\Crate\ReportDataResult;
use PhpOffice\PhpSpreadsheet\Exception;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Symfony\Contracts\Translation\TranslatorInterface;
class ExcelExporter
{
public function __construct(
protected FormatterHelper $formatterHelper,
private TranslatorInterface $translator,
) {
}
/**
* @param string $name
*
* @throws \Exception
*/
public function export(ReportDataResult $reportDataResult, $name, string $output = 'php://output'): void
{
if (!class_exists(Spreadsheet::class)) {
throw new \Exception('PHPSpreadsheet is required to export to Excel spreadsheets');
}
try {
$objPHPExcel = new Spreadsheet();
$objPHPExcel->getProperties()->setTitle($name);
$objPHPExcel->createSheet();
$objPHPExcelSheet = $objPHPExcel->getActiveSheet();
$reportData = $reportDataResult->getData();
$rowCount = 1;
if (empty($reportData)) {
throw new \Exception('No report data to be exported');
}
$headersRow = $reportDataResult->getHeaders();
$this->putHeader($headersRow, $objPHPExcelSheet);
// build the data rows
foreach ($reportData as $count=>$data) {
$row = [];
foreach ($data as $k => $v) {
$type = $reportDataResult->getType($k);
$formatted = htmlspecialchars_decode($this->formatterHelper->_($v, $type, true), ENT_QUOTES);
$row[] = $formatted;
}
// write the row
$rowCount = $count + 2;
$objPHPExcel->getActiveSheet()->fromArray($row, null, "A{$rowCount}");
// free memory
unset($row, $reportData['data'][$count]);
}
// Add totals to export
$totalsRow = $reportDataResult->getTotalsToExport($this->formatterHelper);
if (!empty($totalsRow)) {
$this->putTotals($totalsRow, $objPHPExcelSheet, 'A'.++$rowCount);
}
$objWriter = IOFactory::createWriter($objPHPExcel, 'Xlsx');
$objWriter->setPreCalculateFormulas(false);
$objWriter->save($output);
} catch (Exception $e) {
throw new \Exception('PHPSpreadsheet Error', 0, $e);
}
}
/**
* @param array<string> $headers
*/
public function putHeader(array $headers, Worksheet $activeSheet): void
{
$activeSheet->fromArray($headers);
}
/**
* @param array<string> $totals
*/
public function putTotals(array $totals, Worksheet $activeSheet, string $startCell): void
{
// Put label if the first item is empty
$key = array_key_first($totals);
if (empty($totals[$key])) {
$totals[$key] = $this->translator->trans('mautic.report.report.groupby.totals');
}
$activeSheet->fromArray($totals, null, $startCell);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Mautic\ReportBundle\Model;
use Mautic\CoreBundle\Exception\FilePathException;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\FilePathResolver;
use Mautic\ReportBundle\Exception\FileIOException;
class ExportHandler
{
/**
* @var string
*/
private $dir;
public function __construct(
CoreParametersHelper $coreParametersHelper,
private FilePathResolver $filePathResolver,
) {
$this->dir = $coreParametersHelper->get('report_temp_dir');
}
/**
* @return bool|resource
*
* @throws FileIOException
*/
public function getHandler($fileName)
{
$path = $this->getPath($fileName);
if (false === ($handler = @fopen($path, 'a'))) {
throw new FileIOException('Could not open file '.$path);
}
return $handler;
}
/**
* @param resource $handler
*/
public function closeHandler($handler): void
{
fclose($handler);
}
/**
* @param string $fileName
*/
public function removeFile($fileName): void
{
try {
$path = $this->getPath($fileName);
$this->filePathResolver->delete($path);
} catch (FileIOException) {
}
}
/**
* @throws FileIOException
*/
public function getPath($fileName): string
{
try {
$this->filePathResolver->createDirectory($this->dir);
} catch (FilePathException $e) {
throw new FileIOException('Could not create directory '.$this->dir, 0, $e);
}
return $this->dir.'/'.$fileName.'.csv';
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Mautic\ReportBundle\Model;
use Symfony\Component\HttpFoundation\Response;
class ExportResponse
{
/**
* @param string $fileName
*/
public static function setResponseHeaders(Response $response, $fileName): void
{
$response->headers->set('Content-Type', 'application/octet-stream');
$response->headers->set('Content-Disposition', 'attachment; filename="'.$fileName.'"');
$response->headers->set('Expires', '0');
$response->headers->set('Cache-Control', 'must-revalidate');
$response->headers->set('Pragma', 'public');
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Mautic\ReportBundle\Model;
use Mautic\ReportBundle\Scheduler\Model\FileHandler;
class ReportCleanup
{
public const KEEP_FILE_DAYS = 7;
public function __construct(private FileHandler $fileHandler)
{
}
public function cleanup(int $reportId): void
{
if ($this->shouldBeDeleted($this->fileHandler->getPathToCompressedCsvFileForReportId($reportId))) {
$this->fileHandler->deleteCompressedCsvFileForReportId($reportId);
}
}
/**
* Deletes files older than KEEP_FILE_DAYS.
*/
public function cleanupAll(): void
{
$reportDirectory = $this->fileHandler->getCompressedCsvFileForReportDir();
if (!file_exists($reportDirectory)) {
return;
}
$files = array_diff(scandir($reportDirectory), ['.', '..']);
foreach ($files as $file) {
$filePath = $reportDirectory.'/'.$file;
if (is_dir($filePath)) {
continue;
}
if ($this->shouldBeDeleted($filePath)) {
$this->fileHandler->delete($filePath);
}
}
}
private function shouldBeDeleted(string $filePath): bool
{
if (!file_exists($filePath)) {
return false;
}
$created = new \DateTime(date('Y-m-d', filemtime($filePath)));
$now = new \DateTime();
$days = $created->diff($now)->days;
if ($days > self::KEEP_FILE_DAYS) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace Mautic\ReportBundle\Model;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
class ReportExportOptions
{
/**
* @var int
*/
private $batchSize;
private int $page;
/**
* @var \DateTimeInterface
*/
private $dateFrom;
/**
* @var \DateTimeInterface
*/
private $dateTo;
public function __construct(CoreParametersHelper $coreParametersHelper)
{
$this->batchSize = $coreParametersHelper->get('report_export_batch_size');
$this->page = 1;
}
public function beginExport(): void
{
$this->page = 1;
}
public function nextBatch(): void
{
++$this->page;
}
/**
* @return int
*/
public function getBatchSize()
{
return $this->batchSize;
}
public function getPage(): int
{
return $this->page;
}
/**
* @return int
*/
public function getNumberOfProcessedResults()
{
return $this->page * $this->getBatchSize();
}
/**
* @return \DateTimeInterface
*/
public function getDateFrom()
{
return $this->dateFrom;
}
/**
* @param \DateTime $dateFrom
*/
public function setDateFrom($dateFrom): void
{
$this->dateFrom = $dateFrom;
}
/**
* @return \DateTimeInterface
*/
public function getDateTo()
{
return $this->dateTo;
}
/**
* @param \DateTime $dateTo
*/
public function setDateTo($dateTo): void
{
$this->dateTo = $dateTo;
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace Mautic\ReportBundle\Model;
use Mautic\CoreBundle\Event\JobExtendTimeEvent;
use Mautic\ReportBundle\Adapter\ReportDataAdapter;
use Mautic\ReportBundle\Entity\Scheduler;
use Mautic\ReportBundle\Event\ReportScheduleSendEvent;
use Mautic\ReportBundle\Exception\FileIOException;
use Mautic\ReportBundle\ReportEvents;
use Mautic\ReportBundle\Scheduler\Enum\SchedulerEnum;
use Mautic\ReportBundle\Scheduler\Option\ExportOption;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class ReportExporter
{
public function __construct(
private ScheduleModel $schedulerModel,
private ReportDataAdapter $reportDataAdapter,
private ReportExportOptions $reportExportOptions,
private ReportFileWriter $reportFileWriter,
private EventDispatcherInterface $eventDispatcher,
) {
}
/**
* @throws FileIOException
*/
public function processExport(ExportOption $exportOption): void
{
$schedulers = $this->schedulerModel->getScheduledReportsForExport($exportOption);
foreach ($schedulers as $scheduler) {
$this->processReport($scheduler);
}
}
/**
* @throws FileIOException
*/
private function processReport(Scheduler $scheduler): void
{
$report = $scheduler->getReport();
$dateTo = clone $scheduler->getScheduleDate();
$dateTo->setTime(0, 0, 0);
$dateFrom = clone $dateTo;
switch ($report->getScheduleUnit()) {
case SchedulerEnum::UNIT_NOW:
$dateFrom->sub(new \DateInterval('P10Y'));
$this->schedulerModel->turnOffScheduler($report);
break;
case SchedulerEnum::UNIT_DAILY:
$dateFrom->sub(new \DateInterval('P1D'));
break;
case SchedulerEnum::UNIT_WEEKLY:
$dateFrom->sub(new \DateInterval('P7D'));
break;
case SchedulerEnum::UNIT_MONTHLY:
$dateFrom->sub(new \DateInterval('P1M'));
break;
}
$this->reportExportOptions->setDateFrom($dateFrom);
$this->reportExportOptions->setDateTo($dateTo->sub(new \DateInterval('PT1S')));
// just published reports, but schedule continue
if ($report->isPublished()) {
$this->reportExportOptions->beginExport();
while (true) {
$data = $this->reportDataAdapter->getReportData($report, $this->reportExportOptions);
$this->reportFileWriter->writeReportData($scheduler, $data, $this->reportExportOptions);
$totalResults = $data->getTotalResults();
unset($data);
if ($this->reportExportOptions->getNumberOfProcessedResults() >= $totalResults) {
break;
}
$this->eventDispatcher->dispatch(new JobExtendTimeEvent());
$this->reportExportOptions->nextBatch();
}
$file = $this->reportFileWriter->getFilePath($scheduler);
$event = new ReportScheduleSendEvent($scheduler, $file);
$this->eventDispatcher->dispatch($event, ReportEvents::REPORT_SCHEDULE_SEND);
}
$this->schedulerModel->reportWasScheduled($report);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Mautic\ReportBundle\Model;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\ReportBundle\Crate\ReportDataResult;
use Mautic\ReportBundle\Entity\Scheduler;
use Mautic\ReportBundle\Exception\FileIOException;
class ReportFileWriter
{
public function __construct(
private CsvExporter $csvExporter,
private ExportHandler $exportHandler,
) {
}
/**
* @throws FileIOException
*/
public function writeReportData(Scheduler $scheduler, ReportDataResult $reportDataResult, ReportExportOptions $reportExportOptions): void
{
$fileName = $this->getFileName($scheduler);
$handler = $this->exportHandler->getHandler($fileName);
$this->csvExporter->export($reportDataResult, $handler, $reportExportOptions->getPage());
$this->exportHandler->closeHandler($handler);
}
public function clear(Scheduler $scheduler): void
{
$fileName = $this->getFileName($scheduler);
$this->exportHandler->removeFile($fileName);
}
/**
* @throws FileIOException
*/
public function getFilePath(Scheduler $scheduler): string
{
$fileName = $this->getFileName($scheduler);
return $this->exportHandler->getPath($fileName);
}
private function getFileName(Scheduler $scheduler): string
{
$date = $scheduler->getScheduleDate();
$dateString = $date->format('Y-m-d');
$reportName = $scheduler->getReport()->getName();
return $dateString.'_'.InputHelper::alphanum($reportName, false, '-');
}
}

View File

@@ -0,0 +1,876 @@
<?php
namespace Mautic\ReportBundle\Model;
use Doctrine\DBAL\Connections\PrimaryReadReplicaConnection;
use Doctrine\DBAL\Query\QueryBuilder;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\ChannelBundle\Helper\ChannelListHelper;
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\CoreBundle\Model\GlobalSearchInterface;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Model\FieldModel;
use Mautic\ReportBundle\Builder\MauticReportBuilder;
use Mautic\ReportBundle\Crate\ReportDataResult;
use Mautic\ReportBundle\Entity\Report;
use Mautic\ReportBundle\Event\ReportBuilderEvent;
use Mautic\ReportBundle\Event\ReportDataEvent;
use Mautic\ReportBundle\Event\ReportEvent;
use Mautic\ReportBundle\Event\ReportGraphEvent;
use Mautic\ReportBundle\Event\ReportQueryEvent;
use Mautic\ReportBundle\Generator\ReportGenerator;
use Mautic\ReportBundle\Helper\ReportHelper;
use Mautic\ReportBundle\ReportEvents;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\EventDispatcher\Event;
use Twig\Environment;
/**
* @extends FormModel<Report>
*/
class ReportModel extends FormModel implements GlobalSearchInterface
{
public const CHANNEL_FEATURE = 'reporting';
/**
* @var array
*/
private $reportBuilderData;
/**
* @var mixed
*/
protected $defaultPageLimit;
public function __construct(
CoreParametersHelper $coreParametersHelper,
protected Environment $twig,
protected ChannelListHelper $channelListHelper,
protected FieldModel $fieldModel,
protected ReportHelper $reportHelper,
private CsvExporter $csvExporter,
private ExcelExporter $excelExporter,
EntityManagerInterface $em,
CorePermissions $security,
EventDispatcherInterface $dispatcher,
UrlGeneratorInterface $router,
Translator $translator,
UserHelper $userHelper,
LoggerInterface $mauticLogger,
private RequestStack $requestStack,
) {
$this->defaultPageLimit = $coreParametersHelper->get('default_pagelimit');
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
}
/**
* @return \Mautic\ReportBundle\Entity\ReportRepository
*/
public function getRepository()
{
return $this->em->getRepository(Report::class);
}
public function getPermissionBase(): string
{
return 'report:reports';
}
protected function getSession(): SessionInterface
{
try {
return $this->requestStack->getSession();
} catch (SessionNotFoundException) {
return new Session(); // in case of CLI
}
}
/**
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
*/
public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): \Symfony\Component\Form\FormInterface
{
if (!$entity instanceof Report) {
throw new MethodNotAllowedHttpException(['Report']);
}
if (!empty($action)) {
$options['action'] = $action;
}
$options = array_merge($options, [
'table_list' => $this->getTableData('all', $entity->getSource()),
'attr' => [
'readonly' => false,
],
]);
// Fire the REPORT_ON_BUILD event off to get the table/column data
$reportGenerator = new ReportGenerator($this->dispatcher, $this->em->getConnection(), $entity, $this->channelListHelper, $formFactory);
return $reportGenerator->getForm($entity, $options);
}
public function getEntity($id = null): ?Report
{
if (null === $id) {
return new Report();
}
return parent::getEntity($id);
}
/**
* @throws MethodNotAllowedHttpException
*/
protected function dispatchEvent($action, &$entity, $isNew = false, ?Event $event = null): ?Event
{
if (!$entity instanceof Report) {
throw new MethodNotAllowedHttpException(['Report']);
}
switch ($action) {
case 'pre_save':
$name = ReportEvents::REPORT_PRE_SAVE;
break;
case 'post_save':
$name = ReportEvents::REPORT_POST_SAVE;
break;
case 'pre_delete':
$name = ReportEvents::REPORT_PRE_DELETE;
break;
case 'post_delete':
$name = ReportEvents::REPORT_POST_DELETE;
break;
default:
return null;
}
if ($this->dispatcher->hasListeners($name)) {
if (empty($event)) {
$event = new ReportEvent($entity, $isNew);
$event->setEntityManager($this->em);
}
$this->dispatcher->dispatch($event, $name);
return $event;
} else {
return null;
}
}
/**
* Build the table and graph data.
*
* @return mixed
*/
public function buildAvailableReports($context, ?string $reportSource = null)
{
if (empty($this->reportBuilderData[$context])) {
// Check to see if all has been obtained
if (isset($this->reportBuilderData['all'])) {
$this->reportBuilderData[$context]['tables'] = $this->reportBuilderData['all']['tables'][$context] ?? [];
$this->reportBuilderData[$context]['graphs'] = $this->reportBuilderData['all']['graphs'][$context] ?? [];
} else {
// build them
$eventContext = ('all' == $context) ? '' : $context;
$event = new ReportBuilderEvent($this->translator, $this->channelListHelper, $eventContext, $this->fieldModel->getPublishedFieldArrays(), $this->reportHelper, $reportSource);
$this->dispatcher->dispatch($event, ReportEvents::REPORT_ON_BUILD);
$tables = $event->getTables();
$graphs = $event->getGraphs();
if ('all' == $context) {
$this->reportBuilderData[$context]['tables'] = $tables;
$this->reportBuilderData[$context]['graphs'] = $graphs;
} else {
if (isset($tables[$context])) {
$this->reportBuilderData[$context]['tables'] = $tables[$context];
} else {
$this->reportBuilderData[$context]['tables'] = $tables;
}
if (isset($graphs[$context])) {
$this->reportBuilderData[$context]['graphs'] = $graphs[$context];
} else {
$this->reportBuilderData[$context]['graphs'] = $graphs;
}
}
}
}
return $this->reportBuilderData[$context];
}
/**
* Builds the table lookup data for the report forms.
*
* @param string $context
*
* @return array
*/
public function getTableData($context = 'all', ?string $reportSource = null)
{
$data = $this->buildAvailableReports($context, $reportSource);
$data = (!isset($data['tables'])) ? [] : $data['tables'];
if (array_key_exists('columns', $data)) {
$data['columns'] = $this->preventSameAliases($data['columns']);
}
return $data;
}
/**
* Prevent same aliases using numeric suffixes for each alias.
*/
private function preventSameAliases(array $columns): array
{
$existingAliases = [];
foreach ($columns as $key => $column) {
$alias = $column['alias'];
// Count suffixes
if (!array_key_exists($alias, $existingAliases)) {
$existingAliases[$alias] = 0;
} else {
++$existingAliases[$alias];
}
// Add numeric suffix
if ($existingAliases[$alias] > 0) {
$columns[$key]['alias'] = $alias.$existingAliases[$alias];
}
}
return $columns;
}
/**
* @param string $context
*
* @return mixed
*/
public function getGraphData($context = 'all')
{
$data = $this->buildAvailableReports($context);
return (!isset($data['graphs'])) ? [] : $data['graphs'];
}
/**
* @param string $context
*
* @return \stdClass ['choices' => [], 'choiceHtml' => '', definitions => []]
*/
public function getColumnList($context, $isGroupBy = false): \stdClass
{
$tableData = $this->getTableData($context);
$columns = $tableData['columns'] ?? [];
$return = new \stdClass();
$return->choices = [];
$return->choiceHtml = '';
$return->definitions = [];
foreach ($columns as $column => $data) {
if ($isGroupBy && ('unsubscribed' == $column || 'unsubscribed_ratio' == $column || 'unique_ratio' == $column)) {
continue;
}
if (isset($data['label'])) {
$return->choiceHtml .= "<option value=\"$column\">{$data['label']}</option>\n";
$return->choices[$column] = $data['label'];
$return->definitions[$column] = $data;
}
}
return $return;
}
/**
* @param string $context
*
* return \stdClass{filterList: mixed[], definitions: mixed[], operatorChoices: mixed[], operatorHtml: mixed[], filterListHtml: string}
*/
public function getFilterList($context = 'all'): \stdClass
{
$tableData = $this->getTableData($context);
$return = new \stdClass();
$filters = $tableData['filters'] ?? $tableData['columns'] ?? [];
$return->choices = [];
$return->choiceHtml = '';
$return->definitions = [];
$return->operatorHtml = [];
$return->operatorChoices = [];
foreach ($filters as $filter => $data) {
if (isset($data['label'])) {
$return->definitions[$filter] = $data;
$return->choices[$filter] = $data['label'];
$return->choiceHtml .= "<option value=\"$filter\">{$data['label']}</option>\n";
$return->operatorChoices[$filter] = $this->getOperatorOptions($data);
$return->operatorHtml[$filter] = '';
foreach ($return->operatorChoices[$filter] as $value => $label) {
$return->operatorHtml[$filter] .= "<option value=\"$value\">$label</option>\n";
}
}
}
return $return;
}
/**
* @param string $context
*
* @return \stdClass ['choices' => [], choiceHtml = '']
*/
public function getGraphList($context = 'all'): \stdClass
{
$graphData = $this->getGraphData($context);
$return = new \stdClass();
$return->choices = [];
$return->choiceHtml = '';
// First sort
foreach ($graphData as $key => $details) {
$return->choices[$key] = $this->translator->trans($key).' ('.$this->translator->trans('mautic.report.graph.'.$details['type']).')';
}
natsort($return->choices);
foreach ($return->choices as $key => $value) {
$return->choiceHtml .= '<option value="'.$key.'">'.$value."</option>\n";
}
return $return;
}
/**
* Export report.
*
* @param string $format
* @param int $page
*
* @return StreamedResponse|Response
*
* @throws \Exception
*/
public function exportResults($format, Report $report, ReportDataResult $reportDataResult, $handle = null, $page = null)
{
$date = (new DateTimeHelper())->toLocalString();
$name = str_replace(' ', '_', $date).'_'.InputHelper::alphanum($report->getName(), false, '-');
switch ($format) {
case 'csv':
if (!is_null($handle)) {
$this->csvExporter->export($reportDataResult, $handle, $page);
return;
}
$response = new StreamedResponse(
function () use ($reportDataResult): void {
$handle = fopen('php://output', 'r+');
$this->csvExporter->export($reportDataResult, $handle);
fclose($handle);
}
);
$fileName = $name.'.csv';
ExportResponse::setResponseHeaders($response, $fileName);
return $response;
case 'html':
$content = $this->twig->render(
'@MauticReport/Report/export.html.twig',
[
'pageTitle' => $name,
'report' => $report,
'reportDataResult' => $reportDataResult,
]
);
return new Response($content);
case 'xlsx':
if (!class_exists(Spreadsheet::class)) {
throw new \Exception('PHPSpreadsheet is required to export to Excel spreadsheets');
}
$response = new StreamedResponse(
function () use ($reportDataResult, $name): void {
$this->excelExporter->export($reportDataResult, $name);
}
);
$fileName = $name.'.xlsx';
ExportResponse::setResponseHeaders($response, $fileName);
return $response;
default:
return new Response();
}
}
/**
* Get report data for view rendering.
*
* @return mixed[]
*/
public function getReportData(Report $entity, ?FormFactoryInterface $formFactory = null, array $options = []): array
{
// Clone dateFrom/dateTo because they handled separately in charts
$chartDateFrom = isset($options['dateFrom']) ? clone $options['dateFrom'] : (new \DateTime('-30 days'));
$chartDateTo = isset($options['dateTo']) ? clone $options['dateTo'] : (new \DateTime());
$debugData = [];
// UI doesn't set time so reset it to midnight. API can set time so do not reset it. Using DateTimeImmutable to distinguish.
$resetTime = !(isset($options['dateFrom']) && $options['dateFrom'] instanceof \DateTimeImmutable);
if ($resetTime && isset($options['dateFrom'])) {
$now = new \DateTime();
if (!isset($options['dateTo'])) {
$options['dateTo'] = $now;
}
// Set time to the last second of the "to date" date
if ($now->format('Y-m-d') === $options['dateTo']->format('Y-m-d')) {
$options['dateTo'] = $now->setTime(23, 59, 59);
} else {
$options['dateTo']->setTime(23, 59, 59);
}
// Convert date ranges to UTC for fetching tabular data
$options['dateFrom']->setTimeZone(new \DateTimeZone('UTC'));
$options['dateTo']->setTimeZone(new \DateTimeZone('UTC'));
}
$paginate = !empty($options['paginate']);
$reportPage = $options['reportPage'] ?? 1;
$data = $graphs = [];
$reportGenerator = new ReportGenerator($this->dispatcher, $this->getConnection(), $entity, $this->channelListHelper, $formFactory);
$selectedColumns = $entity->getColumns();
$totalResults = $limit = 0;
// Prepare the query builder
$tableDetails = $this->getTableData($entity->getSource());
$dataColumns = $dataAggregatorColumns = [];
$aggregatorColumns = ($aggregators = $entity->getAggregators()) ? $aggregators : [];
foreach ($aggregatorColumns as $aggregatorColumn) {
$selectedColumns[] = $aggregatorColumn['column'];
// add aggregator columns to dataColumns also
$dataColumns[$aggregatorColumn['function'].' '.$aggregatorColumn['column']] = $aggregatorColumn['column'];
$dataAggregatorColumns[$aggregatorColumn['function'].' '.$aggregatorColumn['column']] = $aggregatorColumn['column'];
}
// Build a reference for column to data column (without table prefix)
foreach ($tableDetails['columns'] as $dbColumn => &$columnData) {
$dataColumns[$columnData['alias']] = $dbColumn;
}
$session = $this->getSession();
$orderBy = '';
$orderByDir = 'ASC';
// make sure to use the session if it's started. Otherwise this is impossible to test:
// Failed to start the session because headers have already been sent by "/var/www/html/vendor/phpunit/phpunit/src/Util/Printer.php" at line 104.
if ($session->isStarted()) {
$orderBy = $session->get('mautic.report.'.$entity->getId().'.orderby', $orderBy);
$orderByDir = $session->get('mautic.report.'.$entity->getId().'.orderbydir', $orderByDir);
}
$dataOptions = [
'order' => (!empty($orderBy)) ? [$orderBy, $orderByDir] : false,
'columns' => $tableDetails['columns'],
'filters' => $tableDetails['filters'] ?? $tableDetails['columns'],
'dateFrom' => $options['dateFrom'] ?? null,
'dateTo' => $options['dateTo'] ?? null,
'dynamicFilters' => $options['dynamicFilters'] ?? [],
];
/** @var QueryBuilder $query */
$query = $reportGenerator->getQuery($dataOptions);
$options['translator'] = $this->translator;
$contentTemplate = $reportGenerator->getContentTemplate();
// set what page currently on so that we can return here after form submission/cancellation
$session = $this->getSession();
if ($session->isStarted()) {
$session->set('mautic.report.'.$entity->getId().'.page', $reportPage);
}
// Reset the orderBy as it causes errors in graphs and the count query in table data
$parts = $query->getQueryParts();
$order = $parts['orderBy'];
$query->resetQueryPart('orderBy');
if (empty($options['ignoreGraphData'])) {
$chartQuery = new ChartQuery($this->em->getConnection(), $chartDateFrom, $chartDateTo);
$options['chartQuery'] = $chartQuery;
// Check to see if this is an update from AJAX
$selectedGraphs = (!empty($options['graphName'])) ? [$options['graphName']] : $entity->getGraphs();
if (!empty($selectedGraphs)) {
$availableGraphs = $this->getGraphData($entity->getSource());
if (empty($query)) {
$query = $reportGenerator->getQuery();
}
$eventGraphs = [];
$defaultGraphOptions = $options;
$defaultGraphOptions['dateFrom'] = $chartDateFrom;
$defaultGraphOptions['dateTo'] = $chartDateTo;
foreach ($selectedGraphs as $g) {
if (isset($availableGraphs[$g])) {
$graphOptions = $availableGraphs[$g]['options'] ?? [];
$graphOptions = array_merge($defaultGraphOptions, $graphOptions);
$eventGraphs[$g] = [
'options' => $graphOptions,
'type' => $availableGraphs[$g]['type'],
];
}
}
$event = new ReportGraphEvent($entity, $eventGraphs, $query);
$this->dispatcher->dispatch($event, ReportEvents::REPORT_ON_GRAPH_GENERATE);
$graphs = $event->getGraphs();
unset($defaultGraphOptions);
}
}
$columnsAllowed = $this->getColumnList($entity->getSource());
$order = $this->getOrderBySanitized($order, $columnsAllowed);
if ($order['hasOrderBy']) {
$query->add('orderBy', $order['orderBy']);
}
// Allow plugin to manipulate the query
$event = new ReportQueryEvent($entity, $query, $totalResults, $dataOptions);
$this->dispatcher->dispatch($event, ReportEvents::REPORT_QUERY_PRE_EXECUTE);
$query = $event->getQuery();
if (empty($options['ignoreTableData']) && !empty($selectedColumns)) {
if ($paginate) {
// Build the options array to pass into the query
if ($session->isStarted()) {
$limit = $session->get('mautic.report.'.$entity->getId().'.limit', $this->defaultPageLimit);
}
if (!empty($options['limit'])) {
$limit = $options['limit'];
$reportPage = $options['page'];
}
$start = (1 === $reportPage) ? 0 : (($reportPage - 1) * $limit);
if ($start < 0) {
$start = 0;
}
if (empty($options['totalResults'])) {
$options['totalResults'] = $totalResults = $this->getTotalCount($query, $debugData);
} else {
$totalResults = $options['totalResults'];
}
if ($limit > 0) {
$query->setFirstResult($start)
->setMaxResults($limit);
}
}
$queryTime = microtime(true);
$data = $query->executeQuery()->fetchAllAssociative();
$queryTime = round((microtime(true) - $queryTime) * 1000);
if ($queryTime >= 1000) {
$queryTime *= 1000;
$queryTime .= 's';
} else {
$queryTime .= 'ms';
}
if (!$paginate) {
$totalResults = count($data);
}
// Allow plugin to manipulate the data
$event = new ReportDataEvent($entity, $data, $totalResults, $dataOptions);
$this->dispatcher->dispatch($event, ReportEvents::REPORT_ON_DISPLAY);
$data = $event->getData();
$dataOptions = $event->getOptions();
}
if ($this->isDebugMode()) {
$debugData['query'] = $query->getSQL();
$params = $query->getParameters();
foreach ($params as $name => $param) {
if (is_array($param)) {
$param = implode("','", $param);
}
$debugData['query'] = str_replace(":$name", "'$param'", $debugData['query']);
}
$debugData['query_time'] = $queryTime ?? 'N/A';
}
foreach ($data as $keys => $lead) {
foreach ($lead as $key => $field) {
$data[$keys][$key] = html_entity_decode((string) $field, ENT_QUOTES);
}
}
return [
'totalResults' => $totalResults,
'data' => $data,
'dataColumns' => $dataColumns,
'graphs' => $graphs,
'contentTemplate' => $contentTemplate,
'columns' => $dataOptions['columns'],
'limit' => ($paginate) ? $limit : 0,
'page' => ($paginate) ? $reportPage : 1,
'dateFrom' => $dataOptions['dateFrom'],
'dateTo' => $dataOptions['dateTo'],
'debug' => $debugData,
'aggregatorColumns' => $dataAggregatorColumns,
];
}
/**
* Sanitize order by array comparing it to the allowed columns.
*
* @param iterable<mixed> $orderBys
*
* @return iterable<mixed>
*/
private function getOrderBySanitized(iterable $orderBys, \stdClass $allowedColumns): iterable
{
$hasOrderBy = false;
foreach ($orderBys as $key => $orderBy) {
if ($this->orderByIsValid($orderBy, $allowedColumns->choices)) {
$hasOrderBy = true;
continue;
}
$orderBys[$key] = '';
}
return [
'orderBy' => $orderBys,
'hasOrderBy' => $hasOrderBy,
];
}
/**
* Check if order by is valid.
*
* @param array<string, string> $allowedColumns
*/
private function orderByIsValid(string $order, array $allowedColumns): bool
{
if (empty($order)) {
return false;
}
$orderBy = $order;
$oderByDirection = '';
if (str_contains($order, ' ')) {
$orderTemp = explode(' ', $order);
$orderBy = $orderTemp[0];
$oderByDirection = $orderTemp[1];
}
if (!array_key_exists($orderBy, $allowedColumns) || !in_array($oderByDirection, ['ASC', 'DESC', ''])) {
return false;
}
return true;
}
/**
* @return mixed[]
*/
public function getReportsWithGraphs(): array
{
$ownedBy = $this->security->isGranted('report:reports:viewother') ? null : $this->userHelper->getUser()->getId();
return $this->getRepository()->findReportsWithGraphs($ownedBy);
}
/**
* Determine what operators should be used for the filter type.
*
* @return mixed|string
*/
private function getOperatorOptions(array $data)
{
if (isset($data['operators'])) {
// Custom operators
$options = $data['operators'];
} else {
$operator = $data['operatorGroup'] ?? $data['type'];
if (!array_key_exists($operator, MauticReportBuilder::OPERATORS)) {
$operator = 'default';
}
$options = MauticReportBuilder::OPERATORS[$operator];
}
foreach ($options as &$label) {
$label = $this->translator->trans($label);
}
return $options;
}
private function getTotalCount(QueryBuilder $qb, array &$debugData): int
{
$countQb = clone $qb;
$countQb->resetQueryParts();
$countQb->select('count(*)')
->from('('.$qb->getSQL().')', 'c');
if ($this->isDebugMode()) {
$debugData['count_query'] = $countQb->getSQL();
}
return (int) $countQb->executeQuery()->fetchOne();
}
/**
* @param int $segmentId
*/
public function getReportsIdsWithDependenciesOnSegment($segmentId): array
{
$search = 'lll.leadlist_id';
$filter = [
'force' => [
['column' => 'r.filters', 'expr' => 'LIKE', 'value'=>'%'.$search.'"%'],
],
];
$entities = $this->getEntities(
[
'filter' => $filter,
]
);
$dependents = [];
foreach ($entities as $entity) {
$retrFilters = $entity->getFilters();
foreach ($retrFilters as $eachFilter) {
if ($eachFilter['column'] == $search && $eachFilter['value'] == $segmentId) {
$dependents[] = $entity->getId();
}
}
}
return $dependents;
}
/**
* @return array<int, int>
*/
public function getReportsIdsWithDependenciesOnEmail(int $emailId): array
{
$search = 'e.id';
$filter = [
'force' => [
['column' => 'r.source', 'expr' => 'IN', 'value'=> ['emails', 'email.stats']],
['column' => 'r.filters', 'expr' => 'LIKE', 'value'=>'%'.$search.'"%'],
],
];
$entities = $this->getEntities(
[
'filter' => $filter,
]
);
$dependents = [];
foreach ($entities as $entity) {
foreach ($entity->getFilters() as $entityFilter) {
if ($entityFilter['column'] == $search && $entityFilter['value'] == $emailId) {
$dependents[] = $entity->getId();
}
}
}
return array_unique($dependents);
}
/**
* @return array<int, int>
*/
public function getReportsIdsWithDependenciesOnTag(int $tagId): array
{
$search = 'tag';
$filter = [
'force' => [
['column' => 'r.filters', 'expr' => 'LIKE', 'value'=>'%'.$search.'"%'],
],
];
$entities = $this->getEntities(
[
'filter' => $filter,
]
);
$dependents = [];
foreach ($entities as $entity) {
foreach ($entity->getFilters() as $entityFilter) {
if ($entityFilter['column'] == $search && (is_array($entityFilter['value']) && in_array($tagId, $entityFilter['value']))) {
$dependents[] = $entity->getId();
}
}
}
return array_unique($dependents);
}
/**
* @return \Doctrine\DBAL\Connection
*/
private function getConnection()
{
$connection = $this->em->getConnection();
if ($connection instanceof PrimaryReadReplicaConnection) {
$connection->ensureConnectedToReplica();
}
return $connection;
}
protected function isDebugMode(): bool
{
return MAUTIC_ENV == 'dev' || $this->coreParametersHelper->get('debug');
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Mautic\ReportBundle\Model;
use Doctrine\ORM\EntityManager;
use Mautic\ReportBundle\Entity\Report;
use Mautic\ReportBundle\Entity\Scheduler;
use Mautic\ReportBundle\Entity\SchedulerRepository;
use Mautic\ReportBundle\Scheduler\Model\SchedulerPlanner;
use Mautic\ReportBundle\Scheduler\Option\ExportOption;
class ScheduleModel
{
/**
* @var SchedulerRepository
*/
private \Doctrine\ORM\EntityRepository $schedulerRepository;
public function __construct(
private EntityManager $entityManager,
private SchedulerPlanner $schedulerPlanner,
) {
$this->schedulerRepository = $entityManager->getRepository(Scheduler::class);
}
/**
* @return Scheduler[]
*/
public function getScheduledReportsForExport(ExportOption $exportOption)
{
return $this->schedulerRepository->getScheduledReportsForExport($exportOption);
}
public function reportWasScheduled(Report $report): void
{
$this->schedulerPlanner->computeScheduler($report);
}
public function turnOffScheduler(Report $report): void
{
$report->setIsScheduled(false);
$this->entityManager->persist($report);
$this->entityManager->flush();
}
}