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,92 @@
class ProjectSelectBox {
constructor(selectElement) {
this.$projectSelect = mQuery(selectElement);
this.init();
}
init() {
this.$projectSelect.on('chosen:no_results', this.attachKeydownListener.bind(this));
}
attachKeydownListener(event) {
const $input = mQuery(event.target).next('.chosen-container').find('.chosen-search-input');
$input.off('keydown').on('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const newValue = $input.val().trim();
if (newValue) {
// Add the new value to the select element as an option
const $newOption = mQuery('<option>').val('project_to_create').text(newValue).prop('selected', true);
this.$projectSelect.append($newOption).trigger('chosen:updated');
this.createProjects(event.target);
}
}
});
}
createProjects(el) {
const newProjectNames = [];
const existingProjectIds = [];
const $projectSelect = mQuery(el);
mQuery('#' + $projectSelect.attr('id') + ' :selected').each(function(i, selected) {
const $option = mQuery(selected);
const selectedId = $option.val();
if ('project_to_create' === selectedId) {
newProjectNames.push($option.text());
} else {
existingProjectIds.push(selectedId);
}
});
if (!newProjectNames.length) {
return;
}
Mautic.activateLabelLoadingIndicator($projectSelect.attr('id'));
Mautic.ajaxActionRequest('project:addProjects', {newProjectNames: JSON.stringify(newProjectNames), existingProjectIds: JSON.stringify(existingProjectIds)}, function(response) {
if (response.projects) {
mQuery('#' + $projectSelect.attr('id')).html(response.projects).trigger('chosen:updated');
}
Mautic.removeLabelLoadingIndicator();
});
}
}
// Listen for the 'chosen:no_results' event on all select elements
mQuery(document).on('chosen:no_results', 'select', function (event) {
const $select = mQuery(event.target);
// Check if the select element has the desired attribute
if ($select.data('action') === 'createProject') {
new ProjectSelectBox($select);
}
});
// Handle entity selection modal opening for project details
mQuery(document).on('change', '#project-entity-selector, #entity-type-selector', function(event) {
const $select = mQuery(this);
const $selectedOption = $select.find('option:selected');
if ($selectedOption.val() && $selectedOption.data('href')) {
// Get the URL and header from data attributes
const url = $selectedOption.data('href');
const header = $selectedOption.data('header');
// Use Mautic's loadAjaxModal function
Mautic.loadAjaxModal('#MauticSharedModal', url, 'GET', header);
// Reset the select to placeholder after opening modal
$select.val('');
// Update chosen if it's a chosen select
if ($select.hasClass('chosen-select')) {
$select.trigger('chosen:updated');
}
}
});

View File

@@ -0,0 +1,27 @@
<?php
return [
'routes' => [
'main' => [
'mautic_project_index' => [
'path' => '/projects/{page}',
'controller' => 'Mautic\ProjectBundle\Controller\ProjectController::indexAction',
],
'mautic_project_action' => [
'path' => '/projects/{objectAction}/{objectId}',
'controller' => 'Mautic\ProjectBundle\Controller\ProjectController::executeAction',
],
],
],
'menu' => [
'main' => [
'project.menu.index' => [
'id' => Mautic\ProjectBundle\Controller\ProjectController::ROUTE_INDEX,
'route' => Mautic\ProjectBundle\Controller\ProjectController::ROUTE_INDEX,
'access' => Mautic\ProjectBundle\Security\Permissions\ProjectPermissions::CAN_VIEW,
'iconClass' => 'ri-archive-stack-fill',
'priority' => 1,
],
],
],
];

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Mautic\CoreBundle\DependencyInjection\MauticCoreExtension;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return function (ContainerConfigurator $configurator): void {
$services = $configurator->services()
->defaults()
->autowire()
->autoconfigure()
->public();
$excludes = [];
$services->load('Mautic\\ProjectBundle\\', '../')
->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}');
$services->load('Mautic\\ProjectBundle\\Entity\\', '../Entity/*Repository.php')
->tag(Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass::REPOSITORY_SERVICE_TAG);
$services->alias('mautic.project.model.project', Mautic\ProjectBundle\Model\ProjectModel::class);
};

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\Controller;
use Mautic\CoreBundle\Controller\AjaxController as CommonAjaxController;
use Mautic\CoreBundle\Controller\AjaxLookupControllerTrait;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\ProjectBundle\Entity\Project;
use Mautic\ProjectBundle\Entity\ProjectRepository;
use Mautic\ProjectBundle\Model\ProjectModel;
use Mautic\ProjectBundle\Security\Permissions\ProjectPermissions;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
final class AjaxController extends CommonAjaxController
{
use AjaxLookupControllerTrait;
public function getLookupChoiceListAction(Request $request, ProjectModel $projectModel): JsonResponse
{
$entityType = $request->query->get('entityType');
if (empty($entityType)) {
return new JsonResponse([]);
}
$searchKey = $request->query->get('searchKey', '');
$searchValue = $request->query->get($searchKey, '');
$filter = $searchValue ?: $request->query->get('search', '');
$limit = (int) $request->query->get('limit', '10');
$start = (int) $request->query->get('start', '0');
$results = $projectModel->getLookupResults($entityType, $filter, $limit, $start);
// Format results to match AjaxLookupControllerTrait structure
$dataArray = [];
foreach ($results as $value => $text) {
$dataArray[] = [
'text' => $text,
'value' => $value,
];
}
return new JsonResponse($dataArray);
}
public function addProjectsAction(Request $request, ProjectModel $projectModel, ProjectRepository $projectRepository, CorePermissions $corePermissions): JsonResponse
{
if (!$corePermissions->isGranted(ProjectPermissions::CAN_ASSOCIATE)) {
$this->accessDenied();
}
$existingProjectIds = json_decode($request->request->get('existingProjectIds'), true);
$newProjectNames = json_decode($request->request->get('newProjectNames'), true);
if ($corePermissions->isGranted(ProjectPermissions::CAN_CREATE)) {
foreach ($newProjectNames as $projectName) {
$project = new Project();
$project->setName($projectName);
$projectModel->saveEntity($project);
$existingProjectIds[] = $project->getId();
}
}
// Get an updated list of projects
$allProjects = $projectRepository->getSimpleList(null, [], 'name');
$projectOptions = '';
foreach ($allProjects as $project) {
$selected = in_array($project['value'], $existingProjectIds) ? ' selected="selected"' : '';
$projectOptions .= '<option'.$selected.' value="'.$project['value'].'">'.$project['label'].'</option>';
}
return $this->sendJsonResponse(['projects' => $projectOptions]);
}
}

View File

@@ -0,0 +1,741 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\Controller;
use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
use Doctrine\ORM\EntityNotFoundException;
use Mautic\CoreBundle\Controller\AbstractFormController;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\ProjectBundle\Entity\Project;
use Mautic\ProjectBundle\Form\Type\ProjectAddEntityType;
use Mautic\ProjectBundle\Form\Type\ProjectEntityType;
use Mautic\ProjectBundle\Model\ProjectModel;
use Mautic\ProjectBundle\Security\Permissions\ProjectPermissions;
use Mautic\ProjectBundle\Service\ProjectEntityLoaderService;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
final class ProjectController extends AbstractFormController
{
public const ROUTE_INDEX = 'mautic_project_index';
private const ROUTE_ACTION = 'mautic_project_action';
private const LINK_ID_INDEX = '#'.self::ROUTE_INDEX;
private const TEMPLATE_INDEX = 'Mautic\ProjectBundle\Controller\ProjectController::indexAction';
private const TEMPLATE_FORM = '@MauticProject/Project/form.html.twig';
public function indexAction(Request $request, ProjectModel $projectModel, CorePermissions $corePermissions, ProjectEntityLoaderService $entityLoader, int $page = 1): Response
{
$session = $request->getSession();
$permissions = $corePermissions->isGranted([
ProjectPermissions::CAN_VIEW,
ProjectPermissions::CAN_EDIT,
ProjectPermissions::CAN_CREATE,
ProjectPermissions::CAN_DELETE,
], 'RETURN_ARRAY');
if (!$permissions[ProjectPermissions::CAN_VIEW]) {
return $this->accessDenied();
}
$this->setListFilters();
$limit = $session->get('mautic.project.limit', $this->coreParametersHelper->get('default_pagelimit'));
$start = (1 === $page) ? 0 : (($page - 1) * $limit);
if ($start < 0) {
$start = 0;
}
$search = $request->get('search', $session->get('mautic.projects.filter', ''));
$session->set('mautic.projects.filter', $search);
$orderBy = $session->get('mautic.projects.orderby', 'p.dateModified');
$orderByDir = $session->get('mautic.projects.orderbydir', 'DESC');
$filter = '';
if ($search) {
$filter = ['string' => $search];
}
$tmpl = $request->isXmlHttpRequest() ? $request->get('tmpl', 'index') : 'index';
$items = $projectModel->getEntities(
[
'start' => $start,
'limit' => $limit,
'filter' => $filter,
'orderBy' => $orderBy,
'orderByDir' => $orderByDir,
]
);
// Calculate entity counts for each project
$entityTypes = $entityLoader->getEntityTypesWithViewPermissions();
foreach ($items as $project) {
$projectEntities = $entityLoader->getProjectEntities($project, $entityTypes);
$totalCount = 0;
foreach ($projectEntities as $entityData) {
$totalCount += $entityData['count'];
}
$project->entitiesCount = $totalCount;
}
$count = count($items);
if ($count && $count < ($start + 1)) {
// the number of entities are now less then the current page so redirect to the last page
if (1 === $count) {
$lastPage = 1;
} else {
$lastPage = (ceil($count / $limit)) ?: 1;
}
$session->set('mautic.projects.page', $lastPage);
$returnUrl = $this->generateUrl(self::ROUTE_INDEX, ['page' => $lastPage]);
return $this->postActionRedirect([
'returnUrl' => $returnUrl,
'viewParameters' => [
'page' => $lastPage,
'tmpl' => $tmpl,
],
'contentTemplate' => self::TEMPLATE_INDEX,
'passthroughVars' => [
'activeLink' => self::LINK_ID_INDEX,
'mauticContent' => 'project',
],
]);
}
$session->set('mautic.project.page', $page);
return $this->delegateView([
'viewParameters' => [
'items' => $items,
'page' => $page,
'limit' => $limit,
'permissions' => $permissions,
'security' => $corePermissions,
'tmpl' => $tmpl,
'currentUser' => $this->user,
'searchValue' => $search,
],
'contentTemplate' => '@MauticProject/Project/list.html.twig',
'passthroughVars' => [
'activeLink' => self::LINK_ID_INDEX,
'route' => $this->generateUrl(self::ROUTE_INDEX, ['page' => $page]),
'mauticContent' => 'projects',
],
]);
}
public function newAction(Request $request, ProjectModel $projectModel, FormFactoryInterface $formFactory, CorePermissions $corePermissions): Response
{
if (!$corePermissions->isGranted(ProjectPermissions::CAN_CREATE)) {
return $this->accessDenied();
}
$project = new Project();
$page = $request->getSession()->get('mautic.project.page', 1);
$returnUrl = $this->generateUrl(self::ROUTE_INDEX, ['page' => $page]);
$action = $this->generateUrl(self::ROUTE_ACTION, ['objectAction' => 'new']);
$form = $this->buildForm($project, $action, $formFactory);
if ('POST' === $request->getMethod()) {
$valid = $this->isFormValid($form);
$cancelled = $this->isFormCancelled($form);
if (!$cancelled && $valid) {
$projectModel->saveEntity($project);
$this->addFlashMessage('mautic.core.notice.created', [
'%name%' => $project->getName(),
'%menu_link%' => self::ROUTE_INDEX,
'%url%' => $this->generateUrl(self::ROUTE_ACTION, [
'objectAction' => 'edit',
'objectId' => $project->getId(),
]),
]);
}
if ($cancelled || ($valid && $this->getFormButton($form, ['buttons', 'save'])->isClicked())) {
return $this->postActionRedirect([
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => self::TEMPLATE_INDEX,
'passthroughVars' => [
'activeLink' => self::LINK_ID_INDEX,
'mauticContent' => 'project',
],
]);
}
if ($valid) {
return $this->editAction($project->getId(), $request, $projectModel, $formFactory, $corePermissions, true);
}
}
return $this->delegateView([
'viewParameters' => [
'form' => $form->createView(),
'entity' => $project,
],
'contentTemplate' => self::TEMPLATE_FORM,
'passthroughVars' => [
'activeLink' => self::LINK_ID_INDEX,
'route' => $this->generateUrl(self::ROUTE_ACTION, ['objectAction' => 'new']),
'mauticContent' => 'project',
],
]);
}
public function editAction(string|int $objectId, Request $request, ProjectModel $projectModel, FormFactoryInterface $formFactory, CorePermissions $corePermissions, bool $ignorePost = false): Response
{
if (!$corePermissions->isGranted(ProjectPermissions::CAN_EDIT)) {
return $this->accessDenied();
}
$postActionVars = $this->getPostActionVars($request, $objectId);
try {
/** @var ?Project $project */
$project = $projectModel->getEntity($objectId);
if (!$project instanceof Project) {
throw new EntityNotFoundException(sprintf('Project with id %s not found.', $objectId));
}
$action = $this->generateUrl(self::ROUTE_ACTION, ['objectAction' => 'edit', 'objectId' => $objectId]);
$form = $this->buildForm($project, $action, $formFactory);
if (!$ignorePost && 'POST' === $request->getMethod()) {
if ($this->isFormCancelled($form)) {
return $this->postActionRedirect($postActionVars);
}
if ($this->isFormValid($form)) {
$projectModel->saveEntity($project, $this->getFormButton($form, ['buttons', 'save'])->isClicked());
$this->addFlashMessage('mautic.core.notice.updated', [
'%name%' => $project->getName(),
'%menu_link%' => self::ROUTE_INDEX,
'%url%' => $this->generateUrl(self::ROUTE_ACTION, [
'objectAction' => 'edit',
'objectId' => $project->getId(),
]),
]);
if ($this->getFormButton($form, ['buttons', 'save'])->isClicked()) {
$contentTemplate = self::TEMPLATE_FORM;
$postActionVars['contentTemplate'] = $contentTemplate;
$postActionVars['forwardController'] = false;
$postActionVars['returnUrl'] = $this->generateUrl(self::ROUTE_ACTION, [
'objectAction' => 'edit',
'objectId' => $project->getId(),
]);
// Re-create the form once more with the fresh project and action.
// The alias was empty on redirect after cloning.
$editAction = $this->generateUrl(self::ROUTE_ACTION, ['objectAction' => 'edit', 'objectId' => $project->getId()]);
$form = $this->buildForm($project, $editAction, $formFactory);
$postActionVars['viewParameters'] = [
'objectAction' => 'edit',
'entity' => $project,
'objectId' => $project->getId(),
'form' => $form->createView(),
];
return $this->postActionRedirect($postActionVars);
}
// Redirect to view action after successful edit
$viewUrl = $this->generateUrl(self::ROUTE_ACTION, [
'objectAction' => 'view',
'objectId' => $project->getId(),
]);
return $this->redirect($viewUrl);
}
}
return $this->delegateView([
'viewParameters' => [
'form' => $form->createView(),
'entity' => $project,
'currentProject' => $project->getId(),
],
'contentTemplate' => self::TEMPLATE_FORM,
'passthroughVars' => [
'activeLink' => self::LINK_ID_INDEX,
'route' => $action,
'mauticContent' => 'project',
],
]);
} catch (AccessDeniedException) {
return $this->accessDenied();
} catch (EntityNotFoundException) {
return $this->postActionRedirect(
array_merge($postActionVars, [
'flashes' => [
[
'type' => 'error',
'msg' => 'mautic.project.error.notfound',
'msgVars' => ['%id%' => $objectId],
],
],
])
);
}
}
/**
* @return array<mixed>
*/
private function getPostActionVars(Request $request, string|int|null $objectId = null): array
{
if ($objectId) {
$returnUrl = $this->generateUrl(self::ROUTE_ACTION, ['objectAction' => 'view', 'objectId' => $objectId]);
$viewParameters = ['objectAction' => 'view', 'objectId' => $objectId];
$contentTemplate = 'Mautic\ProjectBundle\Controller\ProjectController::viewAction';
} else {
$page = $request->getSession()->get('mautic.project.page', 1);
$returnUrl = $this->generateUrl(self::ROUTE_INDEX, ['page' => $page]);
$viewParameters = ['page' => $page];
$contentTemplate = self::TEMPLATE_INDEX;
}
return [
'returnUrl' => $returnUrl,
'viewParameters' => $viewParameters,
'contentTemplate' => $contentTemplate,
'passthroughVars' => [
'activeLink' => self::LINK_ID_INDEX,
'mauticContent' => 'project',
],
];
}
public function viewAction(string|int $objectId, Request $request, ProjectModel $projectModel, CorePermissions $corePermissions, ProjectEntityLoaderService $entityLoader): Response
{
/** @var ?Project $project */
$project = $projectModel->getEntity($objectId);
$page = $request->getSession()->get('mautic.project.page', 1);
if (null === $project) {
$returnUrl = $this->generateUrl(self::ROUTE_INDEX, ['page' => $page]);
return $this->postActionRedirect([
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => self::TEMPLATE_INDEX,
'passthroughVars' => [
'activeLink' => self::LINK_ID_INDEX,
'mauticContent' => 'project',
],
'flashes' => [
[
'type' => 'error',
'msg' => 'mautic.project.error.notfound',
'msgVars' => ['%id%' => $objectId],
],
],
]);
}
if (!$corePermissions->isGranted(ProjectPermissions::CAN_VIEW)) {
return $this->accessDenied();
}
$entityTypes = $entityLoader->getEntityTypesWithViewPermissions();
$projectEntities = $entityLoader->getProjectEntities($project, $entityTypes);
return $this->delegateView([
'returnUrl' => $this->generateUrl(self::ROUTE_ACTION, ['objectAction' => 'view', 'objectId' => $project->getId()]),
'viewParameters' => [
'project' => $project,
'projectEntities' => $projectEntities,
'entityTypes' => $entityTypes,
],
'contentTemplate' => '@MauticProject/Project/details.html.twig',
'passthroughVars' => [
'activeLink' => self::LINK_ID_INDEX,
'mauticContent' => 'project',
],
]);
}
public function deleteAction(string $objectId, Request $request, ProjectModel $projectModel, CorePermissions $corePermissions): Response
{
$page = $request->getSession()->get('mautic.project.page', 1);
$returnUrl = $this->generateUrl(self::ROUTE_INDEX, ['page' => $page]);
$flashes = [];
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => self::TEMPLATE_INDEX,
'passthroughVars' => [
'activeLink' => self::LINK_ID_INDEX,
'mauticContent' => 'project',
],
];
if ('POST' === $request->getMethod()) {
/** @var ?Project $project */
$project = $projectModel->getEntity($objectId);
if (null === $project) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.project.error.notfound',
'msgVars' => ['%id%' => $objectId],
];
} elseif (!$corePermissions->isGranted(ProjectPermissions::CAN_DELETE)) {
return $this->accessDenied();
}
$projectModel->deleteEntity($project);
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.core.notice.deleted',
'msgVars' => [
'%name%' => $project->getName(),
'%id%' => $objectId,
],
];
}
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
}
public function batchDeleteAction(Request $request, ProjectModel $projectModel, CorePermissions $corePermissions): Response
{
$page = $request->getSession()->get('mautic.project.page', 1);
$returnUrl = $this->generateUrl(self::ROUTE_INDEX, ['page' => $page]);
$flashes = [];
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => self::TEMPLATE_INDEX,
'passthroughVars' => [
'activeLink' => self::LINK_ID_INDEX,
'mauticContent' => 'project',
],
];
if ('POST' === $request->getMethod()) {
$ids = json_decode($request->query->get('ids', '{}'));
$deleteIds = [];
// Loop over the IDs to perform access checks pre-delete
foreach ($ids as $objectId) {
$entity = $projectModel->getEntity($objectId);
if (null === $entity) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.project.error.notfound',
'msgVars' => ['%id%' => $objectId],
];
} elseif (!$corePermissions->isGranted(ProjectPermissions::CAN_DELETE)) {
$flashes[] = $this->accessDenied(true);
} else {
$deleteIds[] = $objectId;
}
}
// Delete everything we are able to
if (!empty($deleteIds)) {
try {
$entities = $projectModel->deleteEntities($deleteIds);
} catch (ForeignKeyConstraintViolationException) {
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.project.error.cannotbedeleted',
];
return $this->postActionRedirect(
array_merge($postActionVars, ['flashes' => $flashes])
);
}
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.project.notice.batch_deleted',
'msgVars' => [
'%count%' => count($entities),
],
];
}
}
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
}
public function selectEntityTypeAction(Request $request, ProjectModel $projectModel, CorePermissions $corePermissions, ProjectEntityLoaderService $entityLoader): Response
{
if (!$corePermissions->isGranted(ProjectPermissions::CAN_EDIT)) {
return $this->accessDenied();
}
$projectId = $request->get('objectId');
/** @var ?Project $project */
$project = $projectModel->getEntity($projectId);
if (!$project instanceof Project) {
return $this->notFound();
}
// Get available entity types
$entityTypes = $entityLoader->getEntityTypesWithEditPermissions();
return $this->delegateView([
'viewParameters' => [
'project' => $project,
'entityTypes' => $entityTypes,
],
'contentTemplate' => '@MauticProject/Project/select_entity_type_modal.html.twig',
]);
}
public function addEntityAction(Request $request, ProjectModel $projectModel, CorePermissions $corePermissions, FormFactoryInterface $formFactory, ProjectEntityLoaderService $entityLoader): Response
{
if (!$corePermissions->isGranted(ProjectPermissions::CAN_EDIT)) {
return $this->accessDenied();
}
$projectId = $request->get('objectId');
$entityType = $request->get('entityType');
/** @var ?Project $project */
$project = $projectModel->getEntity($projectId);
if (!$project instanceof Project) {
return $this->notFound();
}
// Validate entity type
$entityTypes = $entityLoader->getEntityTypesWithEditPermissions();
if (!isset($entityTypes[$entityType])) {
$returnUrl = $this->generateUrl(self::ROUTE_ACTION, [
'objectAction' => 'view',
'objectId' => $projectId,
]);
return $this->postActionRedirect([
'returnUrl' => $returnUrl,
'viewParameters' => ['objectAction' => 'view', 'objectId' => $projectId],
'contentTemplate' => 'Mautic\ProjectBundle\Controller\ProjectController::viewAction',
'passthroughVars' => [
'closeModal' => 1,
'route' => false,
],
'flashes' => [
[
'type' => 'error',
'msg' => 'mautic.project.error.invalid_entity_type',
'msgVars' => ['%type%' => $entityType],
],
],
]);
}
// Generate the form action URL
$action = $this->generateUrl('mautic_project_action', [
'objectAction' => 'addEntity',
'objectId' => $project->getId(),
'entityType' => $entityType,
]);
// Create the form
$form = $formFactory->create(ProjectAddEntityType::class, [], [
'action' => $action,
'entityType' => $entityType,
'projectId' => $project->getId(),
]);
if ('POST' === $request->getMethod()) {
$flashes = [];
$returnUrl = $this->generateUrl(self::ROUTE_ACTION, [
'objectAction' => 'view',
'objectId' => $projectId,
]);
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['objectAction' => 'view', 'objectId' => $projectId],
'contentTemplate' => 'Mautic\ProjectBundle\Controller\ProjectController::viewAction',
'passthroughVars' => [
'closeModal' => 1,
'route' => false,
],
];
$isCancelled = $this->isFormCancelled($form);
$isValid = $this->isFormValid($form);
if ($isCancelled || !$isValid) {
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
}
$data = $form->getData();
$entityIds = $data['entityIds'] ?? [];
if (empty($entityIds)) {
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
}
// Get entity types configuration
$entityTypes = $entityLoader->getEntityTypesWithEditPermissions();
if (!isset($entityTypes[$entityType])) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.core.error.badrequest',
];
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
}
$entityConfig = $entityTypes[$entityType];
$addedCount = 0;
foreach ($entityIds as $entityId) {
$entity = $entityConfig->model->getEntity($entityId);
if (!$entity) {
continue;
}
// Check if entity is not already in project
if ($entity->getProjects()->contains($project)) {
continue;
}
$entity->addProject($project);
$this->doctrine->getManager()->persist($entity);
++$addedCount;
}
if ($addedCount > 0) {
$this->doctrine->getManager()->flush();
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.project.notice.entities_added',
'msgVars' => [
'%count%' => $addedCount,
'%project%' => $project->getName(),
],
];
} else {
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.project.notice.no_entities_added',
];
}
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
}
return $this->delegateView([
'viewParameters' => [
'form' => $form->createView(),
'project' => $project,
'entityType' => $entityType,
],
'contentTemplate' => '@MauticProject/Project/add_entity_modal.html.twig',
]);
}
public function removeAction(Request $request, ProjectModel $projectModel, CorePermissions $corePermissions, ProjectEntityLoaderService $entityLoader): Response
{
if (!$corePermissions->isGranted(ProjectPermissions::CAN_EDIT)) {
return $this->accessDenied();
}
$projectId = $request->get('objectId');
$entityType = $request->get('entityType');
$entityId = $request->get('entityId');
$flashes = [];
$returnUrl = $this->generateUrl(self::ROUTE_ACTION, [
'objectAction' => 'view',
'objectId' => $projectId,
]);
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['objectAction' => 'view', 'objectId' => $projectId],
'contentTemplate' => 'Mautic\ProjectBundle\Controller\ProjectController::viewAction',
'passthroughVars' => [
'activeLink' => self::LINK_ID_INDEX,
'mauticContent' => 'project',
],
];
if ('POST' === $request->getMethod()) {
/** @var ?Project $project */
$project = $projectModel->getEntity($projectId);
if (!$project instanceof Project) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.project.error.notfound',
'msgVars' => ['%id%' => $projectId],
];
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
}
// Get entity types configuration
$entityTypes = $entityLoader->getEntityTypesWithEditPermissions();
if (!isset($entityTypes[$entityType])) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.core.error.badrequest',
];
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
}
$entityConfig = $entityTypes[$entityType];
$entity = $entityConfig->model->getEntity($entityId);
if (!$entity) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.core.error.notfound',
];
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
}
// Remove the project from the entity's projects collection
$entity->removeProject($project);
$this->doctrine->getManager()->persist($entity);
$this->doctrine->getManager()->flush();
$entityName = method_exists($entity, 'getName') ? $entity->getName() : (method_exists($entity, 'getTitle') ? $entity->getTitle() : $entity->getId());
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.project.notice.item_removed',
'msgVars' => [
'%name%' => $entityName,
'%project%' => $project->getName(),
],
];
}
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
}
/**
* @return FormInterface<FormInterface>&FormInterface
*/
private function buildForm(Project $project, string $action, FormFactoryInterface $formFactory): FormInterface
{
return $formFactory->create(ProjectEntityType::class, $project, ['action' => $action]);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\DTO;
final readonly class EntityTypeConfig
{
public function __construct(
public string $entityClass,
public string $label,
public ?object $model = null,
) {
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
class MauticProjectExtension extends Extension
{
/**
* @param mixed[] $configs
*/
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Config'));
$loader->load('services.php');
}
}

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\ClassMetadata as OrmClassMetadata;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\FormEntity;
use Mautic\CoreBundle\Entity\UuidInterface;
use Mautic\CoreBundle\Entity\UuidTrait;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Mapping\ClassMetadata;
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('project:projects:view')"),
new Post(security: "is_granted('project:projects:create')"),
new Get(security: "is_granted('project:projects:view')"),
new Put(security: "is_granted('project:projects:edit')"),
new Patch(security: "is_granted('project:projects:edit')"),
new Delete(security: "is_granted('project:projects:delete')"),
],
normalizationContext: [
'groups' => ['project:read'],
'swagger_definition_name' => 'Read',
],
denormalizationContext: [
'groups' => ['project:write'],
'swagger_definition_name' => 'Write',
]
)]
class Project extends FormEntity implements UuidInterface
{
use UuidTrait;
public const TABLE_NAME = 'projects';
#[Groups(['project:read'])]
private ?int $id = null;
#[Groups(['project:read', 'project:write'])]
private ?string $description = null;
#[Groups(['project:read', 'project:write'])]
private ?string $name = null;
/**
* @var mixed[]
*/
#[Groups(['project:read', 'project:write'])]
private array $properties = [];
/**
* Transient property to store the count of entities associated with this project.
* This is not persisted to the database.
*/
public int $entitiesCount = 0;
public function __clone()
{
$this->id = null;
parent::__clone();
}
public static function loadMetadata(OrmClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(self::TABLE_NAME)
->setCustomRepositoryClass(ProjectRepository::class)
->addIndex(['name'], 'project_name');
$builder->addIdColumns();
$builder->addField('properties', Types::JSON);
static::addUuidField($builder);
}
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('project')
->addListProperties(
[
'id',
'name',
]
)
->addProperties(
[
'description',
'properties',
]
)
->build();
}
public static function loadValidatorMetadata(ClassMetadata $metadata): void
{
$metadata->addPropertyConstraint(
'name',
new NotBlank(['message' => 'mautic.core.name.required'])
);
}
public function getId(): ?int
{
return $this->id;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): void
{
$this->isChanged('description', $description);
$this->description = $description;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(?string $name): void
{
$this->isChanged('name', $name);
$this->name = $name;
}
/**
* @return mixed[]
*/
public function getProperties(): array
{
return $this->properties;
}
/**
* @param mixed[] $properties
*/
public function setProperties(array $properties): void
{
$this->isChanged('properties', $properties);
$this->properties = $properties;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
class ProjectRepository extends CommonRepository
{
/**
* @return array<string[]>
*/
protected function getDefaultOrder(): array
{
return [
['p.date_modified', 'ASC'],
];
}
public function getTableAlias(): string
{
return 'p';
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\Entity;
use Doctrine\DBAL\Query\QueryBuilder;
trait ProjectRepositoryTrait
{
/**
* @return array{0: string, 1: array<string, array<int|string>>}
*/
private function handleProjectFilter(QueryBuilder $queryBuilder, string $idColumn, string $xrefTable, string $parentTableAlias, string $projectName, bool $negation): array
{
$queryBuilder->select($idColumn);
$queryBuilder->from(MAUTIC_TABLE_PREFIX.$xrefTable, 'projectxref');
$queryBuilder->innerJoin(
'projectxref',
MAUTIC_TABLE_PREFIX.'projects',
'project',
'project.id = projectxref.project_id'
);
$queryBuilder->where($queryBuilder->expr()->eq('project.name', ':name'));
$queryBuilder->setParameter('name', $projectName);
$ids = $queryBuilder->executeQuery()->fetchFirstColumn() ?: [0];
$ids = array_map(fn ($value) => "'$value'", $ids);
if ($negation) {
$expr = $queryBuilder->expr()->notIn("{$parentTableAlias}.id", $ids);
} else {
$expr = $queryBuilder->expr()->in("{$parentTableAlias}.id", $ids);
}
return [$expr, []];
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
trait ProjectTrait
{
/**
* @var Collection<int, Project>
*/
private Collection $projects;
private function initializeProjects(): void
{
$this->projects = new ArrayCollection();
}
private static function addProjectsField(ClassMetadataBuilder $builder, string $tableName, string $columnName): void
{
$builder->createManyToMany('projects', Project::class)
->setJoinTable($tableName)
->addInverseJoinColumn('project_id', 'id', false, false, 'CASCADE')
->addJoinColumn($columnName, 'id', false, false, 'CASCADE')
->setOrderBy(['name' => 'ASC'])
->setIndexBy('name')
->fetchLazy()
->cascadeMerge()
->cascadePersist()
->cascadeDetach()
->build();
}
private static function addProjectsInLoadApiMetadata(ApiMetadataDriver $metadata, string $groupPrefix): void
{
$metadata->setGroupPrefix($groupPrefix)->addProperties(['projects'])->build();
}
/**
* @param string $prop
* @param mixed $val
*/
protected function isChanged($prop, $val): void
{
if ('projects' === $prop) {
if ($val instanceof Project) {
$this->changes['projects']['added'][] = $val->getName();
} else {
$this->changes['projects']['removed'][] = $val;
}
} else {
parent::isChanged($prop, $val);
}
}
public function addProject(Project $project): void
{
$this->isChanged('projects', $project);
$this->projects[] = $project;
}
public function removeProject(Project $project): bool
{
$this->isChanged('projects', $project->getName());
return $this->projects->removeElement($project);
}
public function getProjects(): Collection
{
return $this->projects;
}
public function setProjects(Collection $projects): self
{
$this->projects = $projects;
return $this;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\Event;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Event dispatched to allow bundles to extend entity type to model key mappings.
*/
final class EntityTypeModelMappingEvent extends Event
{
/**
* @param array<string, string> $mappings
*/
public function __construct(private array $mappings = [])
{
}
/**
* Add a model key mapping.
*/
public function addMapping(string $entityType, string $modelKey): void
{
$this->mappings[$entityType] = $modelKey;
}
/**
* Add multiple model key mappings.
*
* @param array<string, string> $mappings
*/
public function addMappings(array $mappings): void
{
$this->mappings = array_merge($this->mappings, $mappings);
}
/**
* Get all model key mappings.
*
* @return array<string, string>
*/
public function getMappings(): array
{
return $this->mappings;
}
/**
* Get model key for entity type or return entity type if no mapping exists.
*/
public function getModelKey(string $entityType): string
{
return $this->mappings[$entityType] ?? $entityType;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\Event;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Event dispatched to allow bundles to extend entity type normalization mappings.
*/
final class EntityTypeNormalizationEvent extends Event
{
/**
* @param array<string, string> $mappings
*/
public function __construct(private array $mappings = [])
{
}
/**
* Add a normalization mapping.
*/
public function addMapping(string $from, string $to): void
{
$this->mappings[$from] = $to;
}
/**
* Add multiple normalization mappings.
*
* @param array<string, string> $mappings
*/
public function addMappings(array $mappings): void
{
$this->mappings = array_merge($this->mappings, $mappings);
}
/**
* Get all normalization mappings.
*
* @return array<string, string>
*/
public function getMappings(): array
{
return $this->mappings;
}
/**
* Get normalized entity type or return original if no mapping exists.
*/
public function getNormalizedType(string $entityType): string
{
return $this->mappings[$entityType] ?? $entityType;
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\EventListener;
use Mautic\ApiBundle\Event\ApiInitializeEvent;
use Mautic\ApiBundle\Serializer\Exclusion\FieldInclusionStrategy;
use Mautic\AssetBundle\Entity\Asset;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\ChannelBundle\Entity\Message;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\EmailBundle\Entity\Email;
use Mautic\FormBundle\Entity\Form;
use Mautic\LeadBundle\Entity\Company;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\PageBundle\Entity\Page;
use Mautic\PointBundle\Entity\Point;
use Mautic\PointBundle\Entity\Trigger;
use Mautic\SmsBundle\Entity\Sms;
use Mautic\StageBundle\Entity\Stage;
use MauticPlugin\MauticFocusBundle\Entity\Focus;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class ApiSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
ApiInitializeEvent::class=> ['onApiInitializeEvent', 0],
];
}
public function onApiInitializeEvent(ApiInitializeEvent $event): void
{
if (!in_array($event->getEntityClass(), [
Asset::class,
Campaign::class,
Message::class,
DynamicContent::class,
Email::class,
Form::class,
Company::class,
LeadList::class,
Page::class,
Point::class,
Trigger::class,
Sms::class,
Stage::class,
Focus::class,
])) {
return;
}
$event->addSerializerGroup('projectList');
$event->addExclusionStrategy(new FieldInclusionStrategy(['id', 'name'], 1, 'projects'));
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\Form\Type;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<mixed>
*/
final class ProjectAddEntityType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'entityType',
HiddenType::class,
[
'data' => $options['entityType'],
]
);
$builder->add(
'projectId',
HiddenType::class,
[
'data' => $options['projectId'],
]
);
$builder->add(
'entityIds',
ProjectListEntityType::class,
[
'label' => 'mautic.project.form.select_entities',
'required' => true,
'multiple' => true,
'entityType' => $options['entityType'],
'projectId' => $options['projectId'],
]
);
$builder->add(
'buttons',
FormButtonsType::class,
[
'apply_text' => false,
'save_text' => 'mautic.core.form.add',
'save_icon' => 'ri-add-line',
]
);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'entityType' => 'email',
'projectId' => null,
]);
$resolver->setRequired(['entityType', 'projectId']);
$resolver->setAllowedTypes('entityType', 'string');
$resolver->setAllowedTypes('projectId', ['int', 'string']);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\Form\Type;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
final class ProjectEntityType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('buttons', FormButtonsType::class);
$builder->add(
'name',
TextType::class,
[
'label' => 'mautic.core.name',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
]
);
$builder->add(
'description',
TextareaType::class,
[
'required' => false,
'label' => 'mautic.core.description',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control editor'],
]
);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\Form\Type;
use Mautic\CoreBundle\Form\Type\EntityLookupType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<mixed>
*/
final class ProjectListEntityType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'required' => false,
'multiple' => false,
'ajax_lookup_action' => fn (Options $options): string => 'project:getLookupChoiceList&'.http_build_query([
'entityType' => $options['entityType'],
'projectId' => $options['projectId'] ?? null,
]),
'modal_route' => false,
'modal_route_parameters' => [],
'model' => 'project',
'model_lookup_method' => 'getLookupResults',
'lookup_arguments' => fn (Options $options): array => [
'type' => $options['entityType'],
'filter' => '$data',
'limit' => 100,
'start' => 0,
'options' => [
'entityType' => $options['entityType'],
'projectId' => $options['projectId'] ?? null,
],
],
'entityType' => 'email',
'projectId' => null,
'label_parameters' => [],
]);
$resolver->setRequired(['entityType']);
$resolver->setAllowedTypes('entityType', 'string');
$resolver->setAllowedTypes('projectId', ['int', 'string', 'null']);
}
public function getParent(): string
{
return EntityLookupType::class;
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\Form\Type;
use Doctrine\ORM\EntityRepository;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\ProjectBundle\Entity\Project;
use Mautic\ProjectBundle\Security\Permissions\ProjectPermissions;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
final class ProjectType extends AbstractType
{
public function __construct(
private TranslatorInterface $translator,
private CorePermissions $corePermissions,
) {
}
public function configureOptions(OptionsResolver $resolver): void
{
$attr = ['data-placeholder' => $this->translator->trans('mautic.project.mautic.project.select')];
if ($this->corePermissions->isGranted(ProjectPermissions::CAN_CREATE)) {
$attr['data-placeholder'] = $this->translator->trans('mautic.project.select_or_create');
$attr['data-action'] = 'createProject';
$attr['data-no-results-text'] = $this->translator->trans('mautic.project.enter_to_create');
$attr['data-allow-add'] = 'true';
}
$resolver->setDefaults(
[
'label' => 'project.menu.index',
'class' => Project::class,
'query_builder' => fn (EntityRepository $er) => $er->createQueryBuilder('p')->orderBy('p.name', 'ASC'),
'choice_label' => 'name',
'multiple' => true,
'required' => false,
'disabled' => !$this->corePermissions->isGranted(ProjectPermissions::CAN_ASSOCIATE),
'by_reference' => false,
'attr' => $attr,
]
);
}
public function getParent(): string
{
return EntityType::class;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class MauticProjectBundle extends Bundle
{
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\Model;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\AjaxLookupModelInterface;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\ProjectBundle\Entity\Project;
use Mautic\ProjectBundle\Entity\ProjectRepository;
use Mautic\ProjectBundle\Service\ProjectEntityLoaderService;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
final class ProjectModel extends FormModel implements AjaxLookupModelInterface
{
public function __construct(
EntityManagerInterface $em,
CorePermissions $security,
EventDispatcherInterface $dispatcher,
UrlGeneratorInterface $router,
Translator $translator,
UserHelper $userHelper,
LoggerInterface $logger,
CoreParametersHelper $coreParametersHelper,
private ProjectEntityLoaderService $entityLoaderService,
) {
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $logger, $coreParametersHelper);
}
public function getRepository(): ProjectRepository
{
$repository = $this->em->getRepository(Project::class);
\assert($repository instanceof ProjectRepository);
return $repository;
}
/**
* {@inheritDoc}
*
* @param string $type
* @param string $filter
* @param int $limit
* @param int $start
* @param array<string, mixed> $options
*
* @return array<int|string, string>
*/
public function getLookupResults($type, $filter = '', $limit = 10, $start = 0, array $options = []): array
{
// Convert filter to string if it's an array (happens when $data is replaced with actual data)
if (is_array($filter)) {
$filter = implode('|', $filter);
}
// Extract projectId from options if provided
$projectId = $options['projectId'] ?? null;
// Results are already in the correct format (id => name)
return $this->entityLoaderService->getLookupResults($type, (string) $filter, (int) $limit, (int) $start, $projectId);
}
}

View File

@@ -0,0 +1,83 @@
{% if item.projects is not empty %}
<div class="project-group d-inline-flex gap-3 mb-xs">
{% for project in item.projects %}
{% set projectPopover %}
<header class="project-popover__header mt-xs">
<span class="project-popover__icon" aria-hidden="true"><i class="ri-archive-stack-line"></i></span>
<h2 class="project-popover__title type-heading-02 text-interactive">{{ project.name }}</h2>
</header>
<div class="project-popover__meta mb-md">
{% if project.dateModified is not empty %}
<span class="project-popover__meta-text type-label-01 text-secondary">
{{ 'mautic.project.popover.updated.on'|trans({'%date%': project.dateModified.date|date}) }}
</span>
{% endif %}
</div>
<p class="project-popover__description type-body-compact-01 mb-md">
{{ project.description|striptags|slice(0, 150) ~ (project.description|length > 150 ? '...' : '') }}
</p>
<div class="project-popover__actions">
{% set buttons = [] %}
{% if securityIsGranted('project:project:view') %}
{% set buttons = buttons|merge([
{
label: 'mautic.project.popover.view.details'|trans,
href: path('mautic_project_action', {'objectAction': 'view', 'objectId': project.id}),
icon: 'ri-arrow-right-line',
variant: 'tertiary',
size: 'sm',
wide: true,
attributes: {
class: 'project-popover__action mb-xs'
}
}
]) %}
{% endif %}
{% if securityIsGranted('project:project:edit') %}
{% set buttons = buttons|merge([
{
label: 'mautic.project.popover.edit'|trans,
href: path('mautic_project_action', {'objectAction': 'edit', 'objectId': project.id}),
icon: 'ri-edit-line',
variant: 'tertiary',
size: 'sm',
wide: true,
attributes: {
class: 'project-popover__action ml-0'
}
}
]) %}
{% endif %}
{% if buttons|length > 0 %}
{% include '@MauticCore/Helper/button.html.twig' with {
buttons: buttons
} %}
{% endif %}
</div>
<footer class="project-popover__footer type-label-01 text-placeholder mt-lg">
<span class="project-popover__footer-text">{{ 'mautic.project.popover.footer.label'|trans }}</span>
</footer>
{% endset %}
<div class="project-popover" data-toggle="popover" data-content='{{ projectPopover }}' data-html="true" data-container="body" data-placement="bottom" data-trigger="hover" data-delay='{"show": 300, "hide": 2000}'>
{% include '@MauticCore/Helper/_tag.html.twig' with {
tags: [
{
label: project.name,
color: 'brand',
size: 'sm',
}
]
} %}
</div>
{% endfor %}
</div>
{% endif %}

View File

@@ -0,0 +1,93 @@
{% set hasEntities = false %}
{% for entityType, entityData in projectEntities %}
{% if entityData.count > 0 %}
{% set hasEntities = true %}
{% endif %}
{% endfor %}
{% if hasEntities %}
{% for entityType, entityData in projectEntities %}
{% if entityData.count > 0 %}
<div id="{{ entityType }}-list-wrapper" class="panel panel-default mt-md">
<div class="panel-heading pa-0 toolbar--table-toolbar">
<div class="d-flex ai-center jc-space-between list-toolbar toolbar--toolbar-content h-48">
<h4 class="panel-title fw-light mb-0 pl-md">{{ entityData.label }}</h4>
<a class="btn btn-ghost" data-toggle="ajaxmodal" data-target="#MauticSharedModal" href="{{ path('mautic_project_action', {'objectAction': 'addEntity', 'objectId': project.getId(), 'entityType': entityType}) }}" data-header="{{ 'mautic.project.add'|trans }} {{ entityData.label|trans }}" aria-label="{{ 'mautic.project.add'|trans }} {{ entityData.label|trans }}">
<i class="ri-add-line" aria-hidden="true" focusable="false"></i>
<span class="hidden-xs hidden-sm">{{ 'mautic.project.add'|trans }}</span>
</a>
</div>
</div>
<div class="page-list">
<div class="table-responsive">
<table class="table table-hover {{ entityType }}-list mb-0">
<thead>
<tr>
<th class="col-actions"></th>
<th class="col-name col-lg-5">{{ 'mautic.core.name'|trans }}</th>
<th class="visible-lg col-dateAdded">{{ 'mautic.lead.import.label.dateAdded'|trans }}</th>
<th class="visible-lg col-dateModified">{{ 'mautic.lead.import.label.dateModified'|trans }}</th>
<th class="visible-lg col-createdBy">{{ 'mautic.core.createdby'|trans }}</th>
<th class="visible-md visible-lg col-id">{{ 'mautic.core.id'|trans }}</th>
</tr>
</thead>
<tbody>
{% for entity in entityData.entities %}
<tr id="row_{{ entityType }}_{{ entity.getId() }}">
<td class="col-xs-1">
{{- include('@MauticCore/Helper/list_actions.html.twig', {
'item': entity,
'templateButtons': {
'edit': false,
'clone': false,
'delete': false
},
'customButtons': {
'remove': {
'confirm': {
'btnClass': false,
'btnText': 'mautic.project.remove_from_project'|trans,
'message': 'mautic.project.form.confirm_remove'|trans({'%name%': entity.getName(), '%project%': project.getName()}),
'confirmAction': path('mautic_project_action', {'objectAction': 'remove', 'objectId': project.getId(), 'entityType': entityType, 'entityId': entity.getId()}),
'template': 'delete'
},
'iconClass': 'ri-subtract-line',
'priority': 100
}
},
}) -}}
</td>
<td>
<a href="{{ path('mautic_' ~ entityType ~ '_action', {'objectAction': 'view', 'objectId': entity.getId()}) }}" data-toggle="ajax">
{{ entity.getName()|purify }}
</a>
</td>
<td class="visible-lg" title="{{ entity.getDateAdded() ? dateToFullConcat(entity.getDateAdded()) : '' }}">
{{ entity.getDateAdded() ? dateToDate(entity.getDateAdded()) : '' }}
</td>
<td class="visible-lg" title="{{ entity.getDateModified() ? dateToFullConcat(entity.getDateModified()) : '' }}">
{{ entity.getDateModified() ? dateToDate(entity.getDateModified()) : '' }}
</td>
<td class="visible-lg">
{{ entity.getCreatedByUser() ? entity.getCreatedByUser()|escape : '' }}
</td>
<td class="visible-md visible-lg">
<span class="text-muted">{{ entity.getId() }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{% endfor %}
{% else %}
{{- include('@MauticCore/Helper/noresults.html.twig', {
'header': 'mautic.project.no_assigned_entities.header',
'message': 'mautic.project.no_assigned_entities.message'
}) -}}
{% endif %}

View File

@@ -0,0 +1,101 @@
{% extends '@MauticCore/Default/content.html.twig' %}
{% block mauticContent %}project
{% endblock %}
{% block preHeader %}
{{ include('@MauticCore/Helper/page_actions.html.twig', {
'item': project,
'templateButtons': {
'close': true,
},
'routeBase': 'project',
'langVar': 'project',
'targetLabel': 'mautic.project.projects'|trans
}) }}
{% endblock %}
{% block headerTitle %}
{{ project.getName() }}
{% endblock %}
{% block actions %}
{{ include('@MauticCore/Helper/page_actions.html.twig', {
'item': project,
'customButtons': [
{
'attr': {
'data-toggle': 'ajaxmodal',
'data-target': '#MauticSharedModal',
'data-header': 'mautic.project.add_entity'|trans,
'href': path('mautic_project_action', {'objectAction': 'selectEntityType', 'objectId': project.getId()})
},
'iconClass': 'ri-add-line',
'btnText': 'mautic.project.add_entity',
'primary': true
}
],
'nameGetter': 'getName',
'templateButtons': {
'edit': securityIsGranted('project:project:edit'),
'delete': securityIsGranted('project:project:delete'),
},
'routeBase': 'project'
}) }}
{% endblock %}
{% block content %}
<div class="box-layout">
<div class="col-md-12 height-auto">
<div>
<div class="pr-md pl-md pt-lg pb-lg">
<div class="box-layout">
<div class="col-xs-10">
<div class="text-white dark-sm mb-0">{{ project.getDescription()|purify }}</div>
</div>
</div>
</div>
<div class="collapse" id="sms-details">
<div class="pr-md pl-md pb-md">
<div class="panel shd-none mb-0">
<table class="table mb-0">
<tbody>
<tr>
<td width="20%">
<span class="fw-b textTitle">{{ 'mautic.core.id'|trans }}</span>
</td>
<td>{{ project.getId() }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div>
<div class="hr-expand nm">
<span data-toggle="tooltip" title="Detail">
<a href="javascript:void(0)" class="arrow text-muted collapsed" data-toggle="collapse" data-target="#sms-details">
<span class="caret"></span>
{{ 'mautic.core.details'|trans }}
</a>
</span>
</div>
</div>
<!-- Project Items Section -->
<div id="project-items-section" class="mt-lg">
<div class="pr-md pl-md pb-lg">
<div class="row">
<div class="col-xs-12">
{{ include('@MauticProject/Project/_entity_list.html.twig') }}
</div>
</div>
</div>
</div>
</div>
<input name="entityId" id="entityId" type="hidden" value="{{ project.getId()|e }}"/>
</div>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends '@MauticCore/Default/content.html.twig' %}
{% block mauticContent 'project' %}
{% block headerTitle %}
{% if form.vars.data.id %}
{{ 'mautic.project.menu.edit'|trans({'%name%': entity.getName()}) }}
{% else %}
{{ 'mautic.project.menu.new'|trans }}
{% endif %}
{% endblock %}
{% block content %}
{{ form_start(form) }}
<div class="box-layout">
<div class="col-md-9 height-auto">
<div class="row">
<div class="col-xs-12">
<!-- start: tab-content -->
<div class="tab-content pa-md">
<div class="tab-pane fade in active bdr-w-0" id="details">
<div class="row">
<div class="col-xs-12">
{{ form_row(form.name) }}
</div>
</div>
<div class="row">
<div class="col-xs-12">
{{ form_row(form.description) }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{{ form_end(form) }}
{% endblock %}

View File

@@ -0,0 +1,173 @@
{% set isIndex = 'index' == tmpl %}
{% set tmpl = 'list' %}
{% extends isIndex ? '@MauticCore/Default/content.html.twig' : '@MauticCore/Default/raw_output.html.twig' %}
{% block mauticContent 'projects' %}
{% block headerTitle 'project.menu.index'|trans %}
{% block content %}
{% if isIndex %}
<div id="page-list-wrapper" class="{% if items|length > 0 or searchValue is not empty %}panel {% endif %} panel-default">
{{ include('@MauticCore/Helper/list_toolbar.html.twig', {
'searchValue': searchValue,
'action': currentRoute,
'page_actions': {
'templateButtons': {
'new': permissions['project:project:create'],
},
'routeBase': 'project',
'langVar' : 'project.list',
},
'bulk_actions': {
'routeBase': 'project',
'templateButtons': {
'delete': permissions['project:project:delete'],
},
},
'quickFilters': [
{
'search': 'mautic.core.searchcommand.ismine',
'label': 'mautic.core.searchcommand.ismine.label',
'tooltip': 'mautic.core.searchcommand.ismine.description',
'icon': 'ri-user-line'
}
]
}) }}
<div class="page-list">
{% endif %}
{% if items|length > 0 %}
<div class="table-responsive">
<table class="table table-hover" id="projectsTable">
<thead>
<tr>
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'checkall': true,
'target': '#projectsTable',
'langVar': 'project.project',
'routeBase': 'project',
'templateButtons': {
'delete': permissions['project:project:delete']
}
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'projects',
'orderBy': 'p.name',
'text': 'mautic.core.name',
'class': 'col-project-name'
}) }}
<th class="visible-md visible-lg col-project-entities-count">
{{ 'mautic.project.list.thead.project.count'|trans }}
</th>
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'projects',
'orderBy': 'p.dateAdded',
'text': 'mautic.core.date.added',
'class': 'col-project-date-added'
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'projects',
'orderBy': 'p.dateModified',
'text': 'mautic.core.date.modified',
'class': 'col-project-date-modified'
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'projects',
'orderBy': 'p.id',
'text': 'mautic.core.id',
'class': 'visible-md visible-lg col-project-id'
}) }}
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>
{{ include('@MauticCore/Helper/list_actions.html.twig', {
'item': item,
'templateButtons': {
'edit': permissions['project:project:edit'],
'delete': permissions['project:project:delete']
},
'routeBase': 'project',
'langVar': 'project',
'nameGetter': 'getName'
}) }}
</td>
<td>
<div>
{% if permissions['project:project:edit'] %}
<a href="{{ path('mautic_project_action', {'objectAction': 'view', 'objectId': item.getId()}) }}" data-toggle="ajax">
{{ item.getName()|e }}
</a>
{% else %}
{{ item.getName() }}
{% endif %}
</div>
{% if item.getDescription() %}
<div class="text-muted mt-4">
<small>{{ item.getDescription()|purify }}</small>
</div>
{% endif %}
</td>
<td class="visible-md visible-lg">
<a href="{{ path('mautic_project_action', {'objectAction': 'view', 'objectId': item.getId()}) }}" data-toggle="ajax" data-menu-link="mautic_project_index" size="sm" class="label label-gray" {% if 0 == item.entitiesCount|default(0) %}disabled="disabled"{% endif %}>
{{- 'mautic.project.entity.count'|trans({'%count%': item.entitiesCount|default(0)}) -}}
</a>
</td>
<td>
<abbr title="{{ dateToFull(item.getDateAdded) }}">
{{ dateToText(item.getDateAdded) }}
</abbr>
</td>
<td>
<abbr title="{{ dateToFull(item.getDateModified) }}">
{{ dateToText(item.getDateModified) }}
</abbr>
</td>
<td class="visible-md visible-lg">{{ item.getId() }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="panel-footer">
{{ include('@MauticCore/Helper/pagination.html.twig', {
'totalItems': items|length,
'page': page,
'limit': limit,
'baseUrl': path('mautic_project_index'),
'sessionVar': 'project'
}) }}
</div>
</div>
{% else %}
{% if searchValue is not empty %}
{{ include('@MauticCore/Helper/noresults.html.twig') }}
{% else %}
<div class="mt-80 col-md-offset-2 col-lg-offset-3 col-md-8 col-lg-5 height-auto">
{% set childContainer %}
<div class="mt-32 mb-md">
{% include '@MauticCore/Components/pictogram.html.twig' with {
'pictogram': 'asset-management',
'size': '80'
} %}
</div>
{% endset %}
{{ include('@MauticCore/Components/content-block.html.twig', {
heading: 'mautic.project.contentblock.heading'|trans,
subheading: 'mautic.project.contentblock.subheading'|trans,
copy: 'mautic.project.contentblock.copy'|trans,
childContainer: childContainer,
}) }}
</div>
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,25 @@
<div class="row">
<div class="col-md-12">
<div class="mb-lg">
<p class="text-muted">{{ 'mautic.project.select_entity_type.description'|trans }}</p>
</div>
<div class="row">
{% for entityType, entityData in entityTypes %}
<div class="col-md-6 mb-sm">
{% include '@MauticCore/Components/tile.html.twig' with {
type: 'clickable',
content: '<div class="d-flex jc-space-between ai-center"><div class="tile-title">' ~ entityData.label|trans ~ '</div></div>',
href: path('mautic_project_action', {'objectAction': 'addEntity', 'objectId': project.getId(), 'entityType': entityType}),
icon: 'ri-add-line',
attributes: {
'data-toggle': 'ajaxmodal',
'data-target': '#MauticSharedModal',
'data-header': entityData.label|trans
}
} %}
</div>
{% endfor %}
</div>
</div>
</div>

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\Security\Permissions;
use Mautic\CoreBundle\Security\Permissions\AbstractPermissions;
use Mautic\UserBundle\Form\Type\PermissionListType;
use Symfony\Component\Form\FormBuilderInterface;
final class ProjectPermissions extends AbstractPermissions
{
private const PERMISSION_BASE = 'project:project';
public const CAN_VIEW = self::PERMISSION_BASE.':view';
public const CAN_EDIT = self::PERMISSION_BASE.':edit';
public const CAN_CREATE = self::PERMISSION_BASE.':create';
public const CAN_DELETE = self::PERMISSION_BASE.':delete';
public const CAN_ASSOCIATE = self::PERMISSION_BASE.':associate';
/**
* @param mixed[] $params
*/
public function __construct($params)
{
parent::__construct($params);
$this->addStandardPermissions([$this->getName()], false);
// Add the associate permission directly to the permissions array
$this->permissions[$this->getName()]['associate'] = 8;
}
public function getName(): string
{
return 'project';
}
/**
* @param mixed[] $options
* @param mixed[] $data
*/
public function buildForm(FormBuilderInterface &$builder, array $options, array $data): void
{
$builder->add(
self::PERMISSION_BASE,
PermissionListType::class,
[
'choices' => [
'mautic.core.permissions.view' => 'view',
'mautic.core.permissions.associate' => 'associate',
'mautic.core.permissions.edit' => 'edit',
'mautic.core.permissions.create' => 'create',
'mautic.core.permissions.delete' => 'delete',
'mautic.core.permissions.full' => 'full',
],
'label' => 'mautic.project.permissions.project',
'bundle' => $this->getName(),
'level' => $this->getName(),
'data' => (!empty($data[$this->getName()]) ? $data[$this->getName()] : []),
]
);
}
}

View File

@@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\Service;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadataInfo;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\ProjectBundle\DTO\EntityTypeConfig;
use Mautic\ProjectBundle\Entity\Project;
use Mautic\ProjectBundle\Event\EntityTypeModelMappingEvent;
use Mautic\ProjectBundle\Event\EntityTypeNormalizationEvent;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final class ProjectEntityLoaderService
{
/** @var array<string, EntityTypeConfig> */
private array $entityTypesCache = [];
public function __construct(
private EntityManagerInterface $em,
private TranslatorInterface $translator,
private ModelFactory $modelFactory,
private CorePermissions $security,
private EventDispatcherInterface $eventDispatcher,
) {
}
/**
* @param array<string, EntityTypeConfig> $entityTypes
*
* @return array<string, array<string, mixed>>
*/
public function getProjectEntities(Project $project, array $entityTypes): array
{
$results = [];
foreach ($entityTypes as $entityType => $config) {
$repository = $config->model->getRepository();
$entities = $repository->createQueryBuilder('e')
->join('e.projects', 'p')
->where('p.id = :projectId')
->setParameter('projectId', $project->getId())
->orderBy('e.dateModified', 'DESC')
->getQuery()
->getResult();
$results[$entityType] = [
'label' => $config->label,
'entities' => $entities,
'count' => count($entities),
];
}
return $results;
}
/**
* Get entity types filtered by user view permissions.
*
* @return array<string, EntityTypeConfig>
*/
public function getEntityTypesWithViewPermissions(): array
{
return $this->filterEntityTypesByPermission('view');
}
/**
* Get entity types filtered by user edit permissions.
*
* @return array<string, EntityTypeConfig>
*/
public function getEntityTypesWithEditPermissions(): array
{
return $this->filterEntityTypesByPermission('edit');
}
/**
* Get lookup results for entity type (used by EntityLookupType).
*
* @return array<int|string, string>
*/
public function getLookupResults(string $entityType, string $filter = '', int $limit = 10, int $start = 0, ?int $projectId = null): array
{
$entityTypes = $this->getEntityTypes();
if (!isset($entityTypes[$entityType])) {
return [];
}
$entityConfig = $entityTypes[$entityType];
// Check permission before proceeding
if (!$this->hasViewPermissionForEntityType($entityConfig)) {
return [];
}
$repository = $entityConfig->model->getRepository();
// Get the label column for this entity type
$labelColumn = $this->getEntityLabelColumn($entityType);
$qb = $repository->createQueryBuilder('e')
->select('e.id, e.'.$labelColumn.' as name')
->setFirstResult($start);
if ($limit > 0) {
$qb->setMaxResults($limit);
}
// Exclude entities already assigned to the specific project if projectId is provided
if ($projectId) {
// Use LEFT JOIN to find entities that are NOT in the specific project
$qb->leftJoin('e.projects', 'p', 'WITH', 'p.id = :projectId')
->andWhere('p.id IS NULL')
->setParameter('projectId', $projectId);
} else {
// Exclude entities already assigned to any project using QueryBuilder
// Use LEFT JOIN to find entities that are NOT in any project
$qb->leftJoin('e.projects', 'p')
->andWhere('p.id IS NULL');
}
// Add filter if provided
if (!empty($filter)) {
$qb->andWhere('e.'.$labelColumn.' LIKE :filter')
->setParameter('filter', '%'.$filter.'%');
}
$results = $qb->getQuery()->getArrayResult();
// Format results for ProjectModel (id => name associative array)
$choices = [];
foreach ($results as $result) {
$choices[$result['id']] = $result['name'];
}
return $choices;
}
/**
* Check if user has view permission for entity type config.
*/
public function hasViewPermissionForEntityType(EntityTypeConfig $config): bool
{
$permissionBase = $config->model->getPermissionBase();
$permissions = [
$permissionBase.':viewown',
$permissionBase.':viewother',
];
return $this->security->isGranted($permissions, 'MATCH_ONE');
}
/**
* Check if user has edit permission for entity type config.
*/
public function hasEditPermissionForEntityType(EntityTypeConfig $config): bool
{
$permissionBase = $config->model->getPermissionBase();
$permissions = [
$permissionBase.':editown',
$permissionBase.':editother',
];
return $this->security->isGranted($permissions, 'MATCH_ONE');
}
/**
* @return array<string, EntityTypeConfig>
*/
private function getEntityTypes(): array
{
if (!empty($this->entityTypesCache)) {
return $this->entityTypesCache;
}
$allMetadata = $this->em->getMetadataFactory()->getAllMetadata();
foreach ($allMetadata as $metadata) {
$entityClass = $metadata->getName();
foreach ($metadata->getAssociationMappings() as $association) {
if (
ClassMetadataInfo::MANY_TO_MANY === $association['type']
&& Project::class === $association['targetEntity']
) {
$shortName = $metadata->getReflectionClass()->getShortName();
$entityType = $this->normalizeEntityType(strtolower($shortName));
$this->entityTypesCache[$entityType] = new EntityTypeConfig(
entityClass: $entityClass,
label: $this->getEntityLabel($entityType),
model: $this->findModelForEntityType($entityType),
);
break;
}
}
}
// Sort entity types alphabetically by label
uasort($this->entityTypesCache, fn (EntityTypeConfig $a, EntityTypeConfig $b) => strcasecmp($a->label, $b->label));
return $this->entityTypesCache;
}
/**
* Filter entity types by permission type.
*
* @return array<string, EntityTypeConfig>
*/
private function filterEntityTypesByPermission(string $permissionType): array
{
$allEntityTypes = $this->getEntityTypes();
$allowedEntityTypes = [];
foreach ($allEntityTypes as $entityType => $config) {
$hasPermission = 'view' === $permissionType
? $this->hasViewPermissionForEntityType($config)
: $this->hasEditPermissionForEntityType($config);
if ($hasPermission) {
$allowedEntityTypes[$entityType] = $config;
}
}
return $allowedEntityTypes;
}
/**
* Get the label column name for an entity type.
*/
private function getEntityLabelColumn(string $entityType): string
{
return match ($entityType) {
'asset', 'page' => 'title',
default => 'name',
};
}
/**
* Normalize entity type names for consistent usage.
*/
private function normalizeEntityType(string $entityType): string
{
// Create event with default mappings
$event = new EntityTypeNormalizationEvent([
'leadlist' => 'segment',
'lead' => 'contact',
'dynamiccontent' => 'dynamicContent',
'trigger' => 'pointtrigger',
]);
// Dispatch event to allow bundles to add their mappings
$this->eventDispatcher->dispatch($event);
// Return normalized type or original if no mapping exists
return $event->getNormalizedType($entityType);
}
private function getEntityLabel(string $entityType): string
{
// Create the translation key
$translationKeyString = "mautic.{$entityType}.{$entityType}";
// Get the translation
$translated = $this->translator->trans($translationKeyString);
// If translation doesn't exist (returns the key itself), return capitalized entity type
if ($translated === $translationKeyString) {
return ucfirst($entityType);
}
// Return the actual translation
return $translated;
}
private function findModelForEntityType(string $entityType): FormModel
{
// Create event with default model key mappings
$event = new EntityTypeModelMappingEvent([
'segment' => 'lead.list',
'message' => 'channel.message',
'company' => 'lead.company',
'dynamicContent' => 'dynamicContent.dynamicContent',
'pointtrigger' => 'point.trigger',
]);
// Dispatch event to allow bundles to add their model mappings
$this->eventDispatcher->dispatch($event);
// Get model key from event or use entity type as fallback
$modelKey = $event->getModelKey($entityType);
$model = $this->modelFactory->getModel($modelKey);
\assert($model instanceof FormModel);
return $model;
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\Tests\Functional;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\ProjectBundle\Entity\Project;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
/**
* This class should simplify writing functional tests for project search functionality on various entities.
*/
abstract class AbstractProjectSearchTestCase extends MauticMysqlTestCase
{
/**
* @param string[] $expectedEntities
* @param string[] $unexpectedEntities
*/
#[\PHPUnit\Framework\Attributes\DataProvider('searchDataProvider')]
abstract public function testProjectSearch(string $searchTerm, array $expectedEntities, array $unexpectedEntities): void;
/**
* @return \Generator<string, array{searchTerm: string, expectedEntities: array<string>, unexpectedEntities: array<string>}>
*/
abstract public static function searchDataProvider(): \Generator;
/**
* Test and assert API as well as UI.
*
* @param string[] $expectedEntities
* @param string[] $unexpectedEntities
* @param string[] $routes
*/
protected function searchAndAssert(string $searchTerm, array $expectedEntities, array $unexpectedEntities, array $routes): void
{
foreach ($routes as $route) {
$crawler = $this->client->request(Request::METHOD_GET, $route.'?search='.urlencode($searchTerm));
$this->assertResponseIsSuccessful();
$isApiRequest = str_starts_with($route, '/api/');
$content = $isApiRequest ? $this->client->getResponse()->getContent() : $crawler->filter('body')->text();
foreach ($expectedEntities as $expectedEntity) {
Assert::assertStringContainsString($expectedEntity, $content);
}
foreach ($unexpectedEntities as $unexpectedEntity) {
Assert::assertStringNotContainsString($unexpectedEntity, $content);
}
if ($isApiRequest) {
Assert::assertJson($content, 'API response should be of type JSON.');
$this->assertProjectDataInApiResponse(json_decode($content, true));
}
}
}
protected function createProject(string $name): Project
{
$project = new Project();
$project->setName($name);
$this->em->persist($project);
return $project;
}
/**
* @param mixed[] $data
*/
private function assertProjectDataInApiResponse(array $data): void
{
$projectData = $this->getProjectData($data);
if (null === $projectData) {
return;
}
Assert::assertEqualsCanonicalizing(['id', 'name'], array_keys(reset($projectData)),
'Project data should contain only "id" and "name".');
}
/**
* @param mixed[] $data
*
* @return mixed[]|null
*/
private function getProjectData(array $data): ?array
{
foreach ($data as $key => $item) {
if (!is_array($item)) {
continue;
}
if ('projects' === $key && $item) {
return $item;
}
$projectData = $this->getProjectData($item);
if (null !== $projectData) {
return $projectData;
}
}
return null;
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\Tests\Functional\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\ProjectBundle\Entity\Project;
use Mautic\ProjectBundle\Model\ProjectModel;
use PHPUnit\Framework\Assert;
final class AjaxControllerTest extends MauticMysqlTestCase
{
public function testCreatingProjectViaMultiselectInput(): void
{
$projectNames = [
'Yellow Project',
'Blue Project',
'Red Project',
];
/** @var ProjectModel $projectModel */
$projectModel = self::getContainer()->get(ProjectModel::class);
$projects = array_map(
static function (string $projectName) use ($projectModel) {
$project = new Project();
$project->setName($projectName);
$projectModel->saveEntity($project);
return $project;
},
$projectNames
);
$this->client->request(
'POST',
'/s/ajax?action=project:addProjects',
[
'newProjectNames' => json_encode(['Green Project']),
'existingProjectIds' => json_encode([$projects[0]->getId(), $projects[1]->getId()]),
]
);
$this->assertResponseIsSuccessful();
$payload = json_decode($this->client->getResponse()->getContent(), true);
Assert::assertArrayHasKey('projects', $payload);
// The options are orderec alphabetically by name.
Assert::assertSame(
// The Blue Project is selected as it was sent as part of the existingProjectIds.
'<option selected="selected" value="'.$projects[1]->getId().'">'.$projects[1]->getName().'</option>'.
// The Green Project is selected as it was sent as part of the newProjectNames and should have next ID as it was created as 4th.
'<option selected="selected" value="'.($projects[2]->getId() + 1).'">Green Project</option>'.
// The Red Project is NOT selected as it was not sent in the AJAX request but it is listed as unselected option.
'<option value="'.$projects[2]->getId().'">'.$projects[2]->getName().'</option>'.
// The Yellow Project is selected as it was sent as part of the existingProjectIds.
'<option selected="selected" value="'.$projects[0]->getId().'">'.$projects[0]->getName().'</option>',
$payload['projects']
);
}
}

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\Tests\Functional\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Model\EmailModel;
use Mautic\ProjectBundle\Entity\Project;
use Mautic\ProjectBundle\Model\ProjectModel;
final class ProjectAddEntityTest extends MauticMysqlTestCase
{
private Project $testProject;
private Email $testEmail;
protected function setUp(): void
{
parent::setUp();
/** @var ProjectModel $projectModel */
$projectModel = self::getContainer()->get(ProjectModel::class);
// Create test project
$this->testProject = new Project();
$this->testProject->setName('Test Project for Add Entity');
$this->testProject->setDescription('Test project for functional testing');
$projectModel->saveEntity($this->testProject);
// Create test email
/** @var EmailModel $emailModel */
$emailModel = self::getContainer()->get(EmailModel::class);
$this->testEmail = new Email();
$this->testEmail->setName('Test Email for Project');
$this->testEmail->setSubject('Test Email Subject');
$this->testEmail->setEmailType('template');
$this->testEmail->setTemplate('blank');
$emailModel->saveEntity($this->testEmail);
}
public function testSelectEntityTypeActionRendersModal(): void
{
$url = '/s/projects/selectEntityType/'.$this->testProject->getId();
$this->client->request('GET', $url);
$response = $this->client->getResponse();
$content = $response->getContent();
$this->assertResponseIsSuccessful();
$this->assertStringContainsString('entityType=email', $content);
}
public function testSelectEntityTypeActionNotFound(): void
{
$this->client->followRedirects(false);
$this->client->request('GET', '/s/projects/selectEntityType/99999');
$response = $this->client->getResponse();
$this->assertSame(404, $response->getStatusCode());
}
public function testAddEntityActionGetRequest(): void
{
$url = '/s/projects/addEntity/'.$this->testProject->getId().'?entityType=email';
$this->client->request('GET', $url);
$response = $this->client->getResponse();
$content = $response->getContent();
$this->assertResponseIsSuccessful();
// Should contain the form with proper structure
$this->assertStringContainsString('name="project_add_entity"', $content);
$this->assertStringContainsString('project_add_entity[entityType]', $content);
$this->assertStringContainsString('project_add_entity[projectId]', $content);
$this->assertStringContainsString('project_add_entity[entityIds][]', $content);
}
public function testAddEntityActionPostWithValidData(): void
{
// Add email to project directly using the entity relationship
$this->testEmail->addProject($this->testProject);
$this->em->persist($this->testEmail);
$this->em->flush();
// View project page to verify email was added
$url = '/s/projects/view/'.$this->testProject->getId();
$this->client->request('GET', $url);
$response = $this->client->getResponse();
$content = $response->getContent();
$this->assertResponseIsSuccessful();
$this->assertStringContainsString($this->testProject->getName(), $content);
$this->assertStringContainsString($this->testEmail->getName(), $content);
}
public function testAddEntityActionPostWithEmptyData(): void
{
$url = '/s/projects/addEntity/'.$this->testProject->getId().'?entityType=email';
// Get the form
$crawler = $this->client->request('GET', $url);
$this->assertResponseIsSuccessful();
// Submit form with no entities selected
$form = $crawler->filter('form')->first()->form();
$this->client->submit($form);
$this->assertResponseIsSuccessful();
}
public function testAddEntityActionPostWithCancelledForm(): void
{
$url = '/s/projects/addEntity/'.$this->testProject->getId().'?entityType=email';
// Get the form
$crawler = $this->client->request('GET', $url);
$this->assertResponseIsSuccessful();
// Submit form normally (simulating any button press)
$form = $crawler->filter('form')->first()->form();
$this->client->submit($form);
$this->assertResponseIsSuccessful();
}
public function testAddEntityActionWithInvalidEntityType(): void
{
$url = '/s/projects/addEntity/'.$this->testProject->getId().'?entityType=invalid_type';
// Get request with invalid entity type should redirect with error
$this->client->followRedirects();
$this->client->request('GET', $url);
$response = $this->client->getResponse();
$this->assertResponseIsSuccessful();
// Check for error message in flashes
$content = $response->getContent();
$this->assertStringContainsString('Invalid entity type', $content);
}
public function testAddEntityActionNotFound(): void
{
$this->client->followRedirects(false);
$this->client->request('GET', '/s/projects/addEntity/99999?entityType=email');
$response = $this->client->getResponse();
$this->assertSame(404, $response->getStatusCode());
}
public function testAddEntityActionWithoutPermission(): void
{
$user = $this->createAndLoginUser();
$url = '/s/projects/addEntity/'.$this->testProject->getId().'?entityType=email';
$this->client->request('GET', $url);
$this->assertResponseStatusCodeSame(403);
}
private function createAndLoginUser(): \Mautic\UserBundle\Entity\User
{
// Create non-admin role
$role = new \Mautic\UserBundle\Entity\Role();
$role->setName('Test Role');
$role->setIsAdmin(false);
$this->em->persist($role);
// Create non-admin user
$user = new \Mautic\UserBundle\Entity\User();
$user->setFirstName('Test');
$user->setLastName('User');
$user->setUsername('testuser');
$user->setEmail('test@example.com');
$hasher = self::getContainer()->get('security.password_hasher_factory')->getPasswordHasher($user);
$user->setPassword($hasher->hash('password'));
$user->setRole($role);
$this->em->persist($user);
$this->em->flush();
$this->loginUser($user);
return $user;
}
}

View File

@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace Mautic\ProjectBundle\Tests\Functional\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\ProjectBundle\Entity\Project;
use Mautic\ProjectBundle\Entity\ProjectRepository;
use Mautic\ProjectBundle\Model\ProjectModel;
use Mautic\UserBundle\Entity\Role;
use Mautic\UserBundle\Entity\User;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\PasswordHasherInterface;
final class ProjectControllerTest extends MauticMysqlTestCase
{
public const USERNAME = 'johny';
private ProjectRepository $projectRepository;
protected function setUp(): void
{
parent::setUp();
$projects = [
'project1',
'project2',
'project3',
'project4',
];
/** @var ProjectModel $projectModel */
$projectModel = self::getContainer()->get(ProjectModel::class);
$this->projectRepository = $projectModel->getRepository();
foreach ($projects as $projectName) {
$project = new Project();
$project->setName($projectName);
$projectModel->saveEntity($project);
}
}
#[\PHPUnit\Framework\Attributes\DataProvider('indexUrlsProvider')]
public function testIndexActionDisplaysProjects(string $url): void
{
$this->client->request('GET', $url);
$clientResponse = $this->client->getResponse();
$clientResponseContent = $clientResponse->getContent();
$this->assertResponseIsSuccessful();
$this->assertStringContainsString('project1', $clientResponseContent, 'The return must contain project1');
$this->assertStringContainsString('project2', $clientResponseContent, 'The return must contain project2');
}
/**
* @return iterable<string, array<int, string>>
*/
public static function indexUrlsProvider(): iterable
{
yield 'non-existent page nuber' => ['/s/projects/999'];
yield 'main index page with no number (meaning page=1)' => ['/s/projects'];
}
public function testIndexActionWhenFiltered(): void
{
$this->client->request('GET', '/s/projects?search=project1');
$clientResponse = $this->client->getResponse();
$clientResponseContent = $clientResponse->getContent();
$this->assertResponseIsSuccessful();
$this->assertStringContainsString('project1', $clientResponseContent, 'The return must contain project1');
$this->assertStringNotContainsString('project2', $clientResponseContent, 'The return must not contain project2');
}
public function testProjectDeletion(): void
{
$project = $this->projectRepository->findOneBy([]);
$segment = new LeadList();
$segment->setName('Test segment');
$segment->setPublicName('Test segment');
$segment->setAlias('test-segment');
$segment->addProject($project);
$this->em->persist($segment);
$this->em->flush();
$this->em->clear();
$projectId = $project->getId();
$this->client->request('POST', '/s/projects/delete/'.$projectId);
$this->assertResponseIsSuccessful();
$this->assertSame($this->projectRepository->find($projectId), null, 'Assert that project is deleted');
$this->assertCount(0, $this->em->find(LeadList::class, $segment->getId())->getProjects());
}
public function testViewAction(): void
{
$project = $this->projectRepository->findOneBy([]);
$this->client->request('GET', '/s/projects/view/'.$project->getId());
$clientResponse = $this->client->getResponse();
$clientResponseContent = $clientResponse->getContent();
$this->assertResponseIsSuccessful();
$this->assertStringContainsString($project->getName(), $clientResponseContent, 'The return must contain project');
}
public function testViewActionNotFound(): void
{
$this->client->followRedirects(false);
$this->client->request('GET', '/s/projects/view/99999');
$clientResponse = $this->client->getResponse();
$this->assertTrue($clientResponse->isRedirection(), 'Must be redirect response.');
}
public function testEditAction(): void
{
$projectName = 'Test project';
$project = $this->projectRepository->findOneBy([]);
$crawler = $this->client->request('GET', '/s/projects/edit/'.$project->getId());
$clientResponse = $this->client->getResponse();
$clientResponseContent = $clientResponse->getContent();
$this->assertTrue($clientResponse->isOk(), 'Return code must be 200.');
$this->assertStringContainsString('Edit project: '.$project->getName(), $clientResponseContent, 'The return must contain \'Edit project\' text');
$form = $crawler->selectButton('Save & Close')->form();
$form['project_entity[name]']->setValue($projectName);
$this->client->submit($form);
$this->assertSame(1, $this->projectRepository->count(['name' => $projectName]));
}
public function testEditActionNotFound(): void
{
$this->client->followRedirects(false);
$this->client->request('GET', '/s/projects/edit/99999');
$clientResponse = $this->client->getResponse();
$this->assertTrue($clientResponse->isRedirection(), 'Must be redirect response.');
}
public function testNewAction(): void
{
$projectName = 'Test project';
$crawler = $this->client->request('GET', '/s/projects/new');
$this->assertResponseIsSuccessful();
$form = $crawler->selectButton('Save')->form();
$form['project_entity[name]']->setValue($projectName);
$this->client->submit($form);
$this->assertSame(1, $this->projectRepository->count(['name' => $projectName]));
}
public function testBatchDeleteAction(): void
{
$projects = $this->projectRepository->findAll();
$projectsId = array_map(function (Project $project) {
return $project->getId();
}, $projects);
$this->client->request('POST', '/s/projects/batchDelete?ids='.json_encode($projectsId));
$this->assertResponseIsSuccessful();
$this->assertEmpty($this->projectRepository->count([]), 'All projects must be deleted.');
}
public function testEmptyProjectShouldThrowValidationError(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/projects/new');
$this->assertResponseIsSuccessful();
$buttonCrawler = $crawler->selectButton('Save & Close');
$form = $buttonCrawler->form();
$form->setValues(['project_entity[name]' => '']);
$this->client->submit($form);
$this->assertResponseIsSuccessful();
Assert::assertStringContainsString('A name is required.', $this->client->getResponse()->getContent());
}
public function testEditProjectWithNoPermission(): void
{
$this->createAndLoginUser();
$project = $this->projectRepository->findOneBy([]);
$this->client->request(Request::METHOD_GET, '/s/projects/edit/'.$project->getId());
$this->assertResponseStatusCodeSame(403, (string) $this->client->getResponse()->getStatusCode());
}
private function createAndLoginUser(): User
{
// Create non-admin role
$role = $this->createRole();
// Create non-admin user
$user = $this->createUser($role);
$this->em->flush();
$this->em->detach($role);
$this->loginUser($user);
// $this->client->setServerParameter('PHP_AUTH_USER', self::USERNAME);
// $this->client->setServerParameter('PHP_AUTH_PW', 'mautic');
return $user;
}
private function createRole(bool $isAdmin = false): Role
{
$role = new Role();
$role->setName('Role');
$role->setIsAdmin($isAdmin);
$this->em->persist($role);
return $role;
}
private function createUser(Role $role): User
{
$user = new User();
$user->setFirstName('Jhon');
$user->setLastName('Doe');
$user->setUsername(self::USERNAME);
$user->setEmail('john.doe@email.com');
$hasher = self::getContainer()->get('security.password_hasher_factory')->getPasswordHasher($user);
\assert($hasher instanceof PasswordHasherInterface);
$user->setPassword($hasher->hash('mautic'));
$user->setRole($role);
$this->em->persist($user);
return $user;
}
}

View File

@@ -0,0 +1,8 @@
mautic.project.error.notfound="No project with an id of %id% was found!"
mautic.project.notice.batch_deleted="Successfully deleted %count% projects!"
mautic.project.error.cannotbedeleted="Only project without any contacts can be deleted."
mautic.project.already.exists="<a href='%url%' data-toggle='ajax' data-menu-link='%menu_link%'><strong>%name%</strong></a> already exists!"
mautic.project.notice.entities_added="%count% item(s) have been added to project '%project%'"
mautic.project.notice.no_entities_added="No new items were added to the project"
mautic.project.notice.item_removed="'%name%' has been removed from project '%project%'"
mautic.project.error.invalid_entity_type="Invalid entity type '%type%'"

View File

@@ -0,0 +1,41 @@
mautic.project.permissions.header="Project permissions"
mautic.project.permissions.project="Project"
project.menu.index="Projects"
mautic.project.projects="Projects"
mautic.project.header.index="Projects"
mautic.project.lead.searchcommand.list="project"
mautic.project.error.notfound="No project with an id of %id% was found!"
mautic.project.menu.new="Create new project"
mautic.project.menu.edit="Edit project: %name%"
mautic.project.entity.count="{0} No Entities|{1} 1 Entity|]1,Inf[ %count% Entities"
mautic.project.list.thead.project.count="# Entities"
mautic.project.select_or_create="Select or type in a new project"
mautic.project.select="Select a project"
mautic.project.enter_to_create="Hit enter to create "
mautic.project.searchcommand.name="project"
mautic.project.form.confirmdelete="Are you sure you want to delete the project '%name%'? it will remove the references to this project from all the referenced entities. Please review before proceeding."
mautic.core.permissions.associate="Associate with other entities"
mautic.project.popover.updated.on="Updated on %date%"
mautic.project.popover.view.details="View details"
mautic.project.popover.edit="Edit"
mautic.project.popover.footer.label="Project"
mautic.project.associated_entities="Project entities"
mautic.project.no_entities_found="No project entities found"
mautic.project.no_assigned_entities.header="No assigned entities"
mautic.project.no_assigned_entities.message="This project does not have any entities assigned to it yet."
mautic.project.remove_from_project="Remove from project"
mautic.project.form.confirm_remove="Are you sure you want to remove '%name%' from project '%project%'?"
mautic.project.add_entity="Add Entities to Project"
mautic.project.modal.add_entity_title="Add %type% to %project%"
mautic.project.form.select_entities="Select entities to add to project"
mautic.project.form.select_entity_placeholder="Choose a %type% to add..."
mautic.project.form.loading_entities="Loading entities..."
mautic.project.form.notes="Notes"
mautic.project.form.notes_placeholder="Optional notes about this assignment..."
mautic.project.form.add_to_project="Add to Project"
mautic.project.add="Add new to project"
mautic.project.select_entity_type.description="Choose the type of entity you want to add to this project."
mautic.project.available_entity_types="Available Entity Types"
mautic.project.contentblock.heading="Turn your marketing plans into action"
mautic.project.contentblock.subheading="One goal. One folder. All your related resources."
mautic.project.contentblock.copy="Projects are the simplest way to keep everything for a campaign in one spot. Streamline your workflow, manage items as a group, and see how all the pieces fit together."