Merge branch 'master' into migrate_to_sf72

# Conflicts:
#	docs/source/_static/code/exports/BirthdateFilter.php
#	rector.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/ByActivityTypeAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/BySocialActionAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/BySocialIssueAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityLocationAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityPresenceAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityReasonAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityTypeAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityUserAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityUsersAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityUsersJobAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityUsersScopeAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/ByCreatorAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/ByThirdpartyAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/CreatorJobAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/CreatorScopeAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/DateAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/LocationTypeAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/PersonAggregators/HouseholdAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/PersonAggregators/PersonAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Aggregator/PersonsAggregator.php
#	src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/SumActivityDuration.php
#	src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/SumActivityVisitDuration.php
#	src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/ActivityTypeFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/BySocialActionFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/BySocialIssueFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/HasNoActivityFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/PeriodHavingActivityBetweenDatesFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/ActivityDateFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/ActivityPresenceFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/ActivityTypeFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/ActivityUsersFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/ByCreatorFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/CreatorJobFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/CreatorScopeFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/EmergencyFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/LocationFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/LocationTypeFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/ActivityReasonFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/PersonsFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/SentReceivedFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/UserFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/UsersJobFilter.php
#	src/Bundle/ChillActivityBundle/Export/Filter/UsersScopeFilter.php
#	src/Bundle/ChillActivityBundle/Validator/Constraints/ActivityValidity.php
#	src/Bundle/ChillAsideActivityBundle/src/Export/Aggregator/ByActivityTypeAggregator.php
#	src/Bundle/ChillAsideActivityBundle/src/Export/Aggregator/ByUserJobAggregator.php
#	src/Bundle/ChillAsideActivityBundle/src/Export/Aggregator/ByUserScopeAggregator.php
#	src/Bundle/ChillAsideActivityBundle/src/Export/Filter/ByActivityTypeFilter.php
#	src/Bundle/ChillAsideActivityBundle/src/Export/Filter/ByDateFilter.php
#	src/Bundle/ChillAsideActivityBundle/src/Export/Filter/ByUserFilter.php
#	src/Bundle/ChillAsideActivityBundle/src/Export/Filter/ByUserJobFilter.php
#	src/Bundle/ChillAsideActivityBundle/src/Export/Filter/ByUserScopeFilter.php
#	src/Bundle/ChillCalendarBundle/Export/Aggregator/AgentAggregator.php
#	src/Bundle/ChillCalendarBundle/Export/Aggregator/CancelReasonAggregator.php
#	src/Bundle/ChillCalendarBundle/Export/Aggregator/JobAggregator.php
#	src/Bundle/ChillCalendarBundle/Export/Aggregator/LocationAggregator.php
#	src/Bundle/ChillCalendarBundle/Export/Aggregator/LocationTypeAggregator.php
#	src/Bundle/ChillCalendarBundle/Export/Aggregator/MonthYearAggregator.php
#	src/Bundle/ChillCalendarBundle/Export/Aggregator/ScopeAggregator.php
#	src/Bundle/ChillCalendarBundle/Export/Aggregator/UrgencyAggregator.php
#	src/Bundle/ChillCalendarBundle/Export/Filter/AgentFilter.php
#	src/Bundle/ChillCalendarBundle/Export/Filter/BetweenDatesFilter.php
#	src/Bundle/ChillCalendarBundle/Export/Filter/CalendarRangeFilter.php
#	src/Bundle/ChillCalendarBundle/Export/Filter/JobFilter.php
#	src/Bundle/ChillCalendarBundle/Export/Filter/ScopeFilter.php
#	src/Bundle/ChillEventBundle/Export/Aggregator/EventDateAggregator.php
#	src/Bundle/ChillEventBundle/Export/Aggregator/EventTypeAggregator.php
#	src/Bundle/ChillEventBundle/Export/Aggregator/RoleAggregator.php
#	src/Bundle/ChillEventBundle/Export/Filter/EventDateFilter.php
#	src/Bundle/ChillEventBundle/Export/Filter/EventTypeFilter.php
#	src/Bundle/ChillEventBundle/Export/Filter/RoleFilter.php
#	src/Bundle/ChillMainBundle/Controller/ExportController.php
#	src/Bundle/ChillMainBundle/Controller/SavedExportController.php
#	src/Bundle/ChillMainBundle/DependencyInjection/CompilerPass/ExportsCompilerPass.php
#	src/Bundle/ChillMainBundle/Entity/Notification.php
#	src/Bundle/ChillMainBundle/Export/ExportManager.php
#	src/Bundle/ChillMainBundle/Export/Formatter/CSVFormatter.php
#	src/Bundle/ChillMainBundle/Export/Formatter/CSVListFormatter.php
#	src/Bundle/ChillMainBundle/Export/Formatter/CSVPivotedListFormatter.php
#	src/Bundle/ChillMainBundle/Export/Formatter/SpreadSheetFormatter.php
#	src/Bundle/ChillMainBundle/Export/Formatter/SpreadsheetListFormatter.php
#	src/Bundle/ChillMainBundle/Form/SavedExportType.php
#	src/Bundle/ChillMainBundle/Form/Type/DataTransformer/EntityToJsonTransformer.php
#	src/Bundle/ChillMainBundle/Tests/Export/ExportManagerTest.php
#	src/Bundle/ChillMainBundle/Tests/Export/SortExportElementTest.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/AdministrativeLocationAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/ClosingDateAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/ClosingMotiveAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/ConfidentialAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/CreatorJobAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/DurationAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/EmergencyAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/EvaluationAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/GeographicalUnitStatAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/IntensityAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/JobWorkingOnCourseAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/OpeningDateAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/OriginAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/PersonParticipatingAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/ReferrerAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/ReferrerScopeAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/RequestorAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/ScopeAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/ScopeWorkingOnCourseAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/SocialActionAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/SocialIssueAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/StepAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/UserJobAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/UserWorkingOnCourseAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingPeriodStepHistoryAggregators/ByClosingMotiveAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingPeriodStepHistoryAggregators/ByDateAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingPeriodStepHistoryAggregators/ByStepAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/EvaluationAggregators/ByEndDateAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/EvaluationAggregators/ByMaxDateAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/EvaluationAggregators/ByStartDateAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/EvaluationAggregators/EvaluationTypeAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/EvaluationAggregators/HavingEndDateAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/HouseholdAggregators/ChildrenNumberAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/HouseholdAggregators/CompositionAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/PersonAggregators/AdministrativeStatusAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/PersonAggregators/AgeAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/PersonAggregators/ByHouseholdCompositionAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/PersonAggregators/CenterAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/PersonAggregators/EmploymentStatusAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/PersonAggregators/GenderAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/PersonAggregators/HouseholdPositionAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/PersonAggregators/MaritalStatusAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/PersonAggregators/NationalityAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/PersonAggregators/PostalCodeAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/SocialWorkAggregators/ActionTypeAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/SocialWorkAggregators/CreatorAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/SocialWorkAggregators/CreatorJobAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/SocialWorkAggregators/CreatorScopeAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/SocialWorkAggregators/GoalAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/SocialWorkAggregators/GoalResultAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/SocialWorkAggregators/HandlingThirdPartyAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/SocialWorkAggregators/JobAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/SocialWorkAggregators/ReferrerAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/SocialWorkAggregators/ResultAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Aggregator/SocialWorkAggregators/ScopeAggregator.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/ActiveOnDateFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/ActiveOneDayBetweenDatesFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/AdministrativeLocationFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/ClosingMotiveFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/ConfidentialFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/CreatorFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/CreatorJobFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/EmergencyFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/EvaluationFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/GeographicalUnitStatFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/HandlingThirdPartyFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/HasNoReferrerFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/HasTemporaryLocationFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/IntensityFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/NotAssociatedWithAReferenceAddressFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/OpenBetweenDatesFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/OriginFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/ReferrerFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/ReferrerFilterBetweenDates.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/RequestorFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/SocialActionFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/SocialIssueFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/StepFilterBetweenDates.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/StepFilterOnDate.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/UserJobFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingPeriodStepHistoryFilters/ByDateFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingPeriodStepHistoryFilters/ByStepFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/EvaluationFilters/EvaluationTypeFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/EvaluationFilters/MaxDateFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/PersonFilters/AddressRefStatusFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/PersonFilters/AgeFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/PersonFilters/BirthdateFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/PersonFilters/ByHouseholdCompositionFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/PersonFilters/DeadOrAliveFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/PersonFilters/DeathdateFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/PersonFilters/GenderFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/PersonFilters/GeographicalUnitFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/PersonFilters/MaritalStatusFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/PersonFilters/NationalityFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/PersonFilters/ResidentialAddressAtThirdpartyFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/PersonFilters/ResidentialAddressAtUserFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/PersonFilters/WithParticipationBetweenDatesFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/PersonFilters/WithoutHouseholdComposition.php
#	src/Bundle/ChillPersonBundle/Export/Filter/PersonFilters/WithoutParticipationBetweenDatesFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/SocialWorkFilters/CreatorFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/SocialWorkFilters/CreatorJobFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/SocialWorkFilters/CreatorScopeFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/SocialWorkFilters/JobFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/SocialWorkFilters/ReferrerFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/SocialWorkFilters/ScopeFilter.php
#	src/Bundle/ChillPersonBundle/Export/Filter/SocialWorkFilters/SocialWorkTypeFilter.php
#	src/Bundle/ChillPersonBundle/Export/Helper/FilterListAccompanyingPeriodHelper.php
#	src/Bundle/ChillPersonBundle/Security/Authorization/AccompanyingPeriodVoter.php
#	src/Bundle/ChillPersonBundle/Tests/Export/Export/ListAccompanyingPeriodTest.php
#	src/Bundle/ChillPersonBundle/Validator/Constraints/AccompanyingPeriod/AccompanyingPeriodValidity.php
#	src/Bundle/ChillPersonBundle/Validator/Constraints/AccompanyingPeriod/ConfidentialCourseMustHaveReferrer.php
#	src/Bundle/ChillPersonBundle/Validator/Constraints/AccompanyingPeriod/LocationValidity.php
#	src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolder.php
#	src/Bundle/ChillReportBundle/Export/Export/ReportList.php
#	src/Bundle/ChillReportBundle/Export/Filter/ReportDateFilter.php
This commit is contained in:
2025-08-04 16:57:45 +02:00
533 changed files with 17191 additions and 3467 deletions

View File

@@ -14,13 +14,13 @@ namespace Chill\MainBundle;
use Chill\MainBundle\Cron\CronJobInterface;
use Chill\MainBundle\CRUD\CompilerPass\CRUDControllerCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\ACLFlagsCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\ExportsCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\MenuCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\NotificationCounterCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\SearchableServicesCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\TimelineCompilerClass;
use Chill\MainBundle\DependencyInjection\CompilerPass\WidgetsCompilerPass;
use Chill\MainBundle\DependencyInjection\ConfigConsistencyCompilerPass;
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
use Chill\MainBundle\Notification\NotificationHandlerInterface;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Chill\MainBundle\Search\SearchApiInterface;
@@ -62,11 +62,12 @@ class ChillMainBundle extends Bundle
->addTag('chill_main.entity_info_provider');
$container->registerForAutoconfiguration(ProvideRoleInterface::class)
->addTag('chill_main.provide_role');
$container->registerForAutoconfiguration(NotificationFlagProviderInterface::class)
->addTag('chill_main.notification_flag_provider');
$container->addCompilerPass(new SearchableServicesCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new ConfigConsistencyCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new TimelineCompilerClass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new ExportsCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new WidgetsCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new NotificationCounterCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new MenuCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);

View File

@@ -11,36 +11,39 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\DirectExportInterface;
use Chill\MainBundle\Export\ExportConfigNormalizer;
use Chill\MainBundle\Export\ExportConfigProcessor;
use Chill\MainBundle\Export\ExportFormHelper;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Form\SavedExportType;
use Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage;
use Chill\MainBundle\Form\Type\Export\ExportType;
use Chill\MainBundle\Form\Type\Export\FormatterType;
use Chill\MainBundle\Form\Type\Export\PickCenterType;
use Chill\MainBundle\Redis\ChillRedis;
use Chill\MainBundle\Repository\SavedExportRepositoryInterface;
use Chill\MainBundle\Repository\SavedExportOrExportGenerationRepository;
use Chill\MainBundle\Security\Authorization\ChillExportVoter;
use Chill\MainBundle\Security\Authorization\SavedExportVoter;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Component\Security\Core\Security;
/**
* Class ExportController
@@ -51,117 +54,23 @@ class ExportController extends AbstractController
private readonly bool $filterStatsByCenters;
public function __construct(
private readonly ChillRedis $redis,
private readonly ExportManager $exportManager,
private readonly FormFactoryInterface $formFactory,
private readonly LoggerInterface $logger,
private readonly RequestStack $requestStack,
private readonly TranslatorInterface $translator,
private readonly SessionInterface $session,
private readonly EntityManagerInterface $entityManager,
private readonly ExportFormHelper $exportFormHelper,
private readonly SavedExportRepositoryInterface $savedExportRepository,
private readonly Security $security,
ParameterBagInterface $parameterBag,
private readonly MessageBusInterface $messageBus,
private readonly ClockInterface $clock,
private readonly ExportConfigNormalizer $exportConfigNormalizer,
private readonly SavedExportOrExportGenerationRepository $savedExportOrExportGenerationRepository,
private readonly ExportConfigProcessor $exportConfigProcessor,
) {
$this->filterStatsByCenters = $parameterBag->get('chill_main')['acl']['filter_stats_by_center'];
}
#[\Symfony\Component\Routing\Attribute\Route(path: '/{_locale}/exports/download/{alias}', name: 'chill_main_export_download', methods: ['GET'])]
public function downloadResultAction(Request $request, mixed $alias): Response
{
/** @var ExportManager $exportManager */
$exportManager = $this->exportManager;
$export = $exportManager->getExport($alias);
$key = $request->query->get('key', null);
$savedExport = $this->getSavedExportFromRequest($request);
[$dataCenters, $dataExport, $dataFormatter] = $this->rebuildData($key, $savedExport);
$formatterAlias = $exportManager->getFormatterAlias($dataExport['export']);
if (null !== $formatterAlias) {
$formater = $exportManager->getFormatter($formatterAlias);
} else {
$formater = null;
}
$viewVariables = [
'alias' => $alias,
'export' => $export,
'export_group' => $this->getExportGroup($export),
'saved_export' => $savedExport,
];
if ($formater instanceof \Chill\MainBundle\Export\Formatter\CSVListFormatter) {
// due to a bug in php, we add the mime type in the download view
$viewVariables['mime_type'] = 'text/csv';
}
return $this->render('@ChillMain/Export/download.html.twig', $viewVariables);
}
/**
* Generate a report.
*
* This action must work with GET queries.
*
* @param string $alias
*/
#[\Symfony\Component\Routing\Attribute\Route(path: '/{_locale}/exports/generate/{alias}', name: 'chill_main_export_generate', methods: ['GET'])]
public function generateAction(Request $request, $alias): Response
{
/** @var ExportManager $exportManager */
$exportManager = $this->exportManager;
$key = $request->query->get('key', null);
$savedExport = $this->getSavedExportFromRequest($request);
[$dataCenters, $dataExport, $dataFormatter] = $this->rebuildData($key, $savedExport);
return $exportManager->generate(
$alias,
$dataCenters['centers'],
$dataExport['export'],
null !== $dataFormatter ? $dataFormatter['formatter'] : []
);
}
/**
* @throws \RedisException
*/
#[\Symfony\Component\Routing\Attribute\Route(path: '/{_locale}/exports/generate-from-saved/{id}', name: 'chill_main_export_generate_from_saved')]
public function generateFromSavedExport(#[MapEntity(id: 'id')] SavedExport $savedExport): RedirectResponse
{
$this->denyAccessUnlessGranted(SavedExportVoter::GENERATE, $savedExport);
$key = md5(uniqid((string) random_int(0, mt_getrandmax()), false));
$this->redis->setEx($key, 3600, \serialize($savedExport->getOptions()));
return $this->redirectToRoute(
'chill_main_export_download',
[
'alias' => $savedExport->getExportAlias(),
'key' => $key, 'prevent_save' => true,
'returnPath' => $this->generateUrl('chill_main_export_saved_list_my'),
]
);
}
/**
* Render the list of available exports.
*/
#[\Symfony\Component\Routing\Attribute\Route(path: '/{_locale}/exports/', name: 'chill_main_export_index')]
public function indexAction(): Response
{
$exportManager = $this->exportManager;
$exports = $exportManager->getExportsGrouped(true);
return $this->render('@ChillMain/Export/layout.html.twig', [
'grouped_exports' => $exports,
]);
}
/**
* handle the step to build a query for an export.
*
@@ -174,7 +83,7 @@ class ExportController extends AbstractController
* 3. 'generate': gather data from session from the previous steps, and
* make a redirection to the "generate" action with data in query (HTTP GET)
*/
#[\Symfony\Component\Routing\Attribute\Route(path: '/{_locale}/exports/new/{alias}', name: 'chill_main_export_new')]
#[Route(path: '/{_locale}/exports/new/{alias}', name: 'chill_main_export_new')]
public function newAction(Request $request, string $alias): Response
{
// first check for ACL
@@ -198,64 +107,6 @@ class ExportController extends AbstractController
};
}
#[\Symfony\Component\Routing\Attribute\Route(path: '/{_locale}/export/saved/update-from-key/{id}/{key}', name: 'chill_main_export_saved_edit_options_from_key')]
public function editSavedExportOptionsFromKey(#[MapEntity(id: 'id')] SavedExport $savedExport, string $key): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$user = $this->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException();
}
$data = $this->rebuildRawData($key);
$savedExport
->setOptions($data);
$this->entityManager->flush();
return $this->redirectToRoute('chill_main_export_saved_edit', ['id' => $savedExport->getId()]);
}
#[\Symfony\Component\Routing\Attribute\Route(path: '/{_locale}/export/save-from-key/{alias}/{key}', name: 'chill_main_export_save_from_key')]
public function saveFromKey(string $alias, string $key, Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$user = $this->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException();
}
$data = $this->rebuildRawData($key);
$savedExport = new SavedExport();
$savedExport
->setOptions($data)
->setExportAlias($alias)
->setUser($user);
$form = $this->createForm(SavedExportType::class, $savedExport);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->persist($savedExport);
$this->entityManager->flush();
return $this->redirectToRoute('chill_main_export_index');
}
return $this->render(
'@ChillMain/SavedExport/new.html.twig',
[
'form' => $form,
'saved_export' => $savedExport,
]
);
}
/**
* create a form to show on different steps.
*
@@ -263,19 +114,26 @@ class ExportController extends AbstractController
*/
protected function createCreateFormExport(string $alias, string $step, array $data, ?SavedExport $savedExport): FormInterface
{
/** @var ExportManager $exportManager */
$exportManager = $this->exportManager;
$isGenerate = str_starts_with($step, 'generate_');
$canEditFull = $this->security->isGranted(ChillExportVoter::COMPOSE_EXPORT);
if (!$canEditFull && null === $savedExport) {
throw new AccessDeniedHttpException('The user is not allowed to edit all filter, it should edit only SavedExport');
}
$options = match ($step) {
'export', 'generate_export' => [
'export_alias' => $alias,
'picked_centers' => $exportManager->getPickedCenters($data['centers'] ?? []),
'picked_centers' => $this->filterStatsByCenters ? $this->exportFormHelper->getPickedCenters($data) : [],
'can_edit_full' => $canEditFull,
'allowed_filters' => $canEditFull ? null : $this->exportConfigProcessor->retrieveUsedFilters($savedExport->getOptions()['filters']),
'allowed_aggregators' => $canEditFull ? null : $this->exportConfigProcessor->retrieveUsedAggregators($savedExport->getOptions()['aggregators']),
],
'formatter', 'generate_formatter' => [
'export_alias' => $alias,
'formatter_alias' => $exportManager->getFormatterAlias($data['export']),
'aggregator_aliases' => $exportManager->getUsedAggregatorsAliases($data['export']),
'aggregator_aliases' => $exportManager->getUsedAggregatorsAliases($data['export']['aggregators']),
],
default => [
'export_alias' => $alias,
@@ -284,14 +142,14 @@ class ExportController extends AbstractController
$defaultFormData = match ($savedExport) {
null => $this->exportFormHelper->getDefaultData($step, $exportManager->getExport($alias), $options),
default => $this->exportFormHelper->savedExportDataToFormData($savedExport, $step, $options),
default => $this->exportFormHelper->savedExportDataToFormData($savedExport, $step),
};
$builder = $this->formFactory
->createNamedBuilder(
'',
FormType::class,
$defaultFormData,
'centers' === $step ? ['centers' => $defaultFormData] : $defaultFormData,
[
'method' => $isGenerate ? Request::METHOD_GET : Request::METHOD_POST,
'csrf_protection' => !$isGenerate,
@@ -328,7 +186,7 @@ class ExportController extends AbstractController
$exportManager = $this->exportManager;
// check we have data from the previous step (export step)
$data = $this->requestStack->getSession()->get('centers_step', []);
$data = $this->session->get('centers_step', []);
if (null === $data && true === $this->filterStatsByCenters) {
return $this->redirectToRoute('chill_main_export_new', [
@@ -350,11 +208,11 @@ class ExportController extends AbstractController
// store data for reusing in next steps
$data = $form->getData();
$this->requestStack->getSession()->set(
$this->session->set(
'export_step_raw',
$request->request->all()
);
$this->requestStack->getSession()->set('export_step', $data);
$this->session->set('export_step', $data);
// redirect to next step
return $this->redirectToRoute('chill_main_export_new', [
@@ -368,7 +226,7 @@ class ExportController extends AbstractController
}
return $this->render('@ChillMain/Export/new.html.twig', [
'form' => $form,
'form' => $form->createView(),
'export_alias' => $alias,
'export' => $export,
'export_group' => $this->getExportGroup($export),
@@ -384,7 +242,7 @@ class ExportController extends AbstractController
private function formatterFormStep(Request $request, DirectExportInterface|ExportInterface $export, string $alias, ?SavedExport $savedExport = null): Response
{
// check we have data from the previous step (export step)
$data = $this->requestStack->getSession()->get('export_step', null);
$data = $this->session->get('export_step', null);
if (null === $data) {
return $this->redirectToRoute('chill_main_export_new', [
@@ -400,8 +258,8 @@ class ExportController extends AbstractController
if ($form->isValid()) {
$dataFormatter = $form->getData();
$this->requestStack->getSession()->set('formatter_step', $dataFormatter);
$this->requestStack->getSession()->set(
$this->session->set('formatter_step', $dataFormatter);
$this->session->set(
'formatter_step_raw',
$request->request->all()
);
@@ -418,7 +276,7 @@ class ExportController extends AbstractController
return $this->render(
'@ChillMain/Export/new_formatter_step.html.twig',
[
'form' => $form,
'form' => $form->createView(),
'export' => $export,
'export_group' => $this->getExportGroup($export),
]
@@ -430,14 +288,17 @@ class ExportController extends AbstractController
* and redirect to the `generate` action.
*
* The data from previous steps is removed from session.
*
* @param string $alias
*/
private function forwardToGenerate(Request $request, DirectExportInterface|ExportInterface $export, $alias, ?SavedExport $savedExport): RedirectResponse
private function forwardToGenerate(Request $request, DirectExportInterface|ExportInterface $export, $alias, ?SavedExport $savedExport): Response
{
$dataCenters = $this->requestStack->getSession()->get('centers_step_raw', null);
$dataFormatter = $this->requestStack->getSession()->get('formatter_step_raw', null);
$dataExport = $this->requestStack->getSession()->get('export_step_raw', null);
$user = $this->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('only regular users can generate export');
}
$dataCenters = $this->session->get('centers_step_raw', null);
$dataFormatter = $this->session->get('formatter_step_raw', null);
$dataExport = $this->session->get('export_step_raw', null);
if (null === $dataFormatter && $export instanceof ExportInterface) {
return $this->redirectToRoute('chill_main_export_new', [
@@ -447,60 +308,82 @@ class ExportController extends AbstractController
]);
}
$parameters = [
'formatter' => $dataFormatter ?? [],
'export' => $dataExport ?? [],
'centers' => $dataCenters ?? [],
'alias' => $alias,
];
unset($parameters['_token']);
$key = md5(uniqid((string) random_int(0, mt_getrandmax()), false));
$dataToNormalize = $this->buildExportDataForNormalization(
$alias,
$dataCenters,
$dataExport,
$dataFormatter,
$savedExport,
);
$this->redis->setEx($key, 3600, \serialize($parameters));
$deleteAt = $this->clock->now()->add(new \DateInterval('P6M'));
$options = $this->exportConfigNormalizer->normalizeConfig($alias, $dataToNormalize);
$exportGeneration = match (null === $savedExport) {
true => new ExportGeneration($alias, $options, $deleteAt),
false => ExportGeneration::fromSavedExport($savedExport, $deleteAt, $options),
};
$this->entityManager->persist($exportGeneration);
$this->entityManager->flush();
$this->messageBus->dispatch(new ExportRequestGenerationMessage($exportGeneration, $user));
// remove data from session
$this->requestStack->getSession()->remove('export_step_raw');
$this->requestStack->getSession()->remove('export_step');
$this->requestStack->getSession()->remove('formatter_step_raw');
$this->requestStack->getSession()->remove('formatter_step');
$this->session->remove('centers_step_raw');
$this->session->remove('export_step_raw');
$this->session->remove('export_step');
$this->session->remove('formatter_step_raw');
$this->session->remove('formatter_step');
return $this->redirectToRoute('chill_main_export_download', [
'key' => $key,
'alias' => $alias,
'from_saved' => $savedExport?->getId(),
]);
return $this->redirectToRoute('chill_main_export-generation_wait', ['id' => $exportGeneration->getId()]);
}
private function rebuildData($key, ?SavedExport $savedExport)
/**
* Build the export form data into a way suitable for normalization.
*
* @param string $alias the export alias
* @param array $dataCenters Raw data from center step
* @param array $dataExport Raw data from export step
* @param array $dataFormatter Raw data from formatter step
*/
private function buildExportDataForNormalization(string $alias, ?array $dataCenters, array $dataExport, array $dataFormatter, ?SavedExport $savedExport): array
{
$rawData = $this->rebuildRawData($key);
$alias = $rawData['alias'];
if ($this->filterStatsByCenters) {
$formCenters = $this->createCreateFormExport($alias, 'generate_centers', [], $savedExport);
$formCenters->submit($rawData['centers']);
$dataCenters = $formCenters->getData();
$formCenters = $this->createCreateFormExport($alias, 'generate_centers', [], null);
$formCenters->submit($dataCenters);
$dataAsCollection = $formCenters->getData()['centers'];
$centers = $dataAsCollection['centers'];
$regroupments = $dataAsCollection['regroupments'] ?? [];
$dataCenters = [
'centers' => $centers instanceof Collection ? $centers->toArray() : $centers,
'regroupments' => $regroupments instanceof Collection ? $regroupments->toArray() : $regroupments,
];
} else {
$dataCenters = ['centers' => []];
$dataCenters = ['centers' => [], 'regroupments' => []];
}
$formExport = $this->createCreateFormExport($alias, 'generate_export', $dataCenters, $savedExport);
$formExport->submit($rawData['export']);
$formExport->submit($dataExport);
$dataExport = $formExport->getData();
if (\count($rawData['formatter']) > 0) {
if (\count($dataFormatter) > 0) {
$formFormatter = $this->createCreateFormExport(
$alias,
'generate_formatter',
$dataExport,
$savedExport
);
$formFormatter->submit($rawData['formatter']);
$formFormatter->submit($dataFormatter);
$dataFormatter = $formFormatter->getData();
}
return [$dataCenters, $dataExport, $dataFormatter ?? null];
return [
'centers' => ['centers' => $dataCenters['centers'], 'regroupments' => $dataCenters['regroupments']],
'export' => $dataExport['export']['export'] ?? [],
'filters' => $dataExport['export']['filters'] ?? [],
'aggregators' => $dataExport['export']['aggregators'] ?? [],
'pick_formatter' => $dataExport['export']['pick_formatter']['alias'],
'formatter' => $dataFormatter['formatter'] ?? [],
];
}
/**
@@ -508,7 +391,7 @@ class ExportController extends AbstractController
*
* @return Response
*/
private function selectCentersStep(Request $request, DirectExportInterface|ExportInterface $export, $alias, ?SavedExport $savedExport = null): RedirectResponse|Response
private function selectCentersStep(Request $request, DirectExportInterface|ExportInterface $export, $alias, ExportGeneration|SavedExport|null $savedExport = null)
{
if (!$this->filterStatsByCenters) {
return $this->redirectToRoute('chill_main_export_new', [
@@ -521,7 +404,12 @@ class ExportController extends AbstractController
/** @var ExportManager $exportManager */
$exportManager = $this->exportManager;
$form = $this->createCreateFormExport($alias, 'centers', [], $savedExport);
$form = $this->createCreateFormExport(
$alias,
'centers',
$this->exportFormHelper->getDefaultData('centers', $export, []),
$savedExport
);
if (Request::METHOD_POST === $request->getMethod()) {
$form->handleRequest($request);
@@ -537,17 +425,17 @@ class ExportController extends AbstractController
false === $exportManager->isGrantedForElement(
$export,
null,
$exportManager->getPickedCenters($data['centers'])
$this->exportFormHelper->getPickedCenters($data['centers']),
)
) {
throw $this->createAccessDeniedException('you do not have access to this export for those centers');
}
$this->requestStack->getSession()->set(
$this->session->set(
'centers_step_raw',
$request->request->all()
);
$this->requestStack->getSession()->set('centers_step', $data);
$this->session->set('centers_step', $data['centers']);
return $this->redirectToRoute('chill_main_export_new', [
'step' => $this->getNextStep('centers', $export),
@@ -560,7 +448,7 @@ class ExportController extends AbstractController
return $this->render(
'@ChillMain/Export/new_centers_step.html.twig',
[
'form' => $form,
'form' => $form->createView(),
'export' => $export,
'export_group' => $this->getExportGroup($export),
]
@@ -631,43 +519,15 @@ class ExportController extends AbstractController
}
}
private function rebuildRawData(?string $key): array
{
if (null === $key) {
throw $this->createNotFoundException('key does not exists');
}
if (1 !== $this->redis->exists($key)) {
$this->addFlash('error', $this->translator->trans('This report is not available any more'));
throw $this->createNotFoundException('key does not exists');
}
$serialized = $this->redis->get($key);
if (false === $serialized) {
throw new \LogicException('the key could not be reached from redis');
}
$rawData = \unserialize($serialized);
$this->logger->notice('[export] choices for an export unserialized', [
'key' => $key,
'rawData' => json_encode($rawData, JSON_THROW_ON_ERROR),
]);
return $rawData;
}
private function getSavedExportFromRequest(Request $request): ?SavedExport
private function getSavedExportFromRequest(Request $request): SavedExport|ExportGeneration|null
{
$savedExport = match ($savedExportId = $request->query->get('from_saved', '')) {
'' => null,
default => $this->savedExportRepository->find($savedExportId),
default => $this->savedExportOrExportGenerationRepository->findById($savedExportId),
};
if (null !== $savedExport && !$this->security->isGranted(SavedExportVoter::EDIT, $savedExport)) {
throw new AccessDeniedHttpException('saved export edition not allowed');
if (null !== $savedExport && !$this->security->isGranted(SavedExportVoter::GENERATE, $savedExport)) {
throw new AccessDeniedHttpException('saved export generation not allowed');
}
return $savedExport;

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Export\ExportManager;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
use Twig\Environment;
final readonly class ExportGenerationController
{
public function __construct(
private Security $security,
private Environment $twig,
private SerializerInterface $serializer,
private ExportManager $exportManager,
) {}
#[Route('/{_locale}/main/export-generation/{id}/wait', methods: ['GET'], name: 'chill_main_export-generation_wait')]
public function wait(ExportGeneration $exportGeneration): Response
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException('Only users can download an export');
}
$export = $this->exportManager->getExport($exportGeneration->getExportAlias());
return new Response(
$this->twig->render('@ChillMain/ExportGeneration/wait.html.twig', ['exportGeneration' => $exportGeneration, 'export' => $export]),
);
}
#[Route('/api/1.0/main/export-generation/{id}/object', methods: ['GET'])]
public function objectStatus(ExportGeneration $exportGeneration): JsonResponse
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException('Only users can download an export');
}
return new JsonResponse(
$this->serializer->serialize(
$exportGeneration,
'json',
[AbstractNormalizer::GROUPS => ['read']],
),
json: true,
);
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage;
use Chill\MainBundle\Security\Authorization\SavedExportVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
class ExportGenerationCreateFromSavedExportController
{
public function __construct(
private readonly Security $security,
private readonly EntityManagerInterface $entityManager,
private readonly MessageBusInterface $messageBus,
private readonly ClockInterface $clock,
private readonly SerializerInterface $serializer,
) {}
#[Route('/api/1.0/main/export/export-generation/create-from-saved-export/{id}', methods: ['POST'])]
public function __invoke(SavedExport $export): JsonResponse
{
if (!$this->security->isGranted(SavedExportVoter::GENERATE, $export)) {
throw new AccessDeniedHttpException('Not allowed to generate an export from this saved export');
}
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Only users can create exports');
}
$exportGeneration = ExportGeneration::fromSavedExport($export, $this->clock->now()->add(new \DateInterval('P6M')));
$this->entityManager->persist($exportGeneration);
$this->entityManager->flush();
$this->messageBus->dispatch(new ExportRequestGenerationMessage($exportGeneration, $user));
return new JsonResponse(
$this->serializer->serialize($exportGeneration, 'json', ['groups' => ['read']]),
json: true,
);
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Repository\ExportGenerationRepository;
use Chill\MainBundle\Security\Authorization\ChillExportVoter;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Twig\Environment;
final readonly class ExportIndexController
{
public function __construct(
private ExportManager $exportManager,
private Environment $twig,
private ExportGenerationRepository $exportGenerationRepository,
private Security $security,
) {}
/**
* Render the list of available exports.
*/
#[Route(path: '/{_locale}/exports/', name: 'chill_main_export_index')]
public function indexAction(ExportController $exportController): Response
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Only regular user can see this page');
}
if (!$this->security->isGranted(ChillExportVoter::COMPOSE_EXPORT)) {
throw new AccessDeniedHttpException(sprintf('Require the %s role', ChillExportVoter::COMPOSE_EXPORT));
}
$exports = $this->exportManager->getExportsGrouped(true);
$lastExecutions = [];
foreach ($this->exportManager->getExports() as $alias => $export) {
$lastExecutions[$alias] = $this->exportGenerationRepository->findExportGenerationByAliasAndUser($alias, $user, 5);
}
return new Response(
$this->twig->render('@ChillMain/Export/layout.html.twig', [
'grouped_exports' => $exports,
'last_executions' => $lastExecutions,
]),
);
}
}

View File

@@ -16,6 +16,7 @@ use Chill\MainBundle\Entity\NotificationComment;
use Chill\MainBundle\Form\NotificationCommentType;
use Chill\MainBundle\Form\NotificationType;
use Chill\MainBundle\Notification\Exception\NotificationHandlerNotFound;
use Chill\MainBundle\Notification\FlagProviders\NotificationByUserFlagProvider;
use Chill\MainBundle\Notification\NotificationHandlerManager;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\NotificationRepository;
@@ -58,7 +59,8 @@ class NotificationController extends AbstractController
$notification
->setRelatedEntityClass($request->query->get('entityClass'))
->setRelatedEntityId($request->query->getInt('entityId'))
->setSender($this->security->getUser());
->setSender($this->security->getUser())
->setType(NotificationByUserFlagProvider::FLAG);
$tos = $request->query->all('tos');

View File

@@ -11,13 +11,13 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\ExportDescriptionHelper;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Export\GroupedExportInterface;
use Chill\MainBundle\Form\SavedExportType;
use Chill\MainBundle\Repository\SavedExportRepositoryInterface;
use Chill\MainBundle\Security\Authorization\ExportGenerationVoter;
use Chill\MainBundle\Security\Authorization\SavedExportVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
@@ -27,15 +27,28 @@ use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class SavedExportController
final readonly class SavedExportController
{
public function __construct(private readonly \Twig\Environment $templating, private readonly EntityManagerInterface $entityManager, private readonly ExportManager $exportManager, private readonly FormFactoryInterface $formFactory, private readonly SavedExportRepositoryInterface $savedExportRepository, private readonly Security $security, private readonly RequestStack $requestStack, private readonly TranslatorInterface $translator, private readonly UrlGeneratorInterface $urlGenerator) {}
public function __construct(
private \Twig\Environment $templating,
private EntityManagerInterface $entityManager,
private ExportManager $exportManager,
private FormFactoryInterface $formFactory,
private Security $security,
private TranslatorInterface $translator,
private UrlGeneratorInterface $urlGenerator,
private ExportDescriptionHelper $exportDescriptionHelper,
private RequestStack $requestStack,
) {}
#[\Symfony\Component\Routing\Attribute\Route(path: '/{_locale}/exports/saved/{id}/delete', name: 'chill_main_export_saved_delete')]
public function delete(#[MapEntity(id: 'id')] SavedExport $savedExport, Request $request): Response
@@ -52,6 +65,10 @@ class SavedExportController
$this->entityManager->remove($savedExport);
$this->entityManager->flush();
$session = $this->requestStack->getSession();
if ($session instanceof Session) {
$session->getFlashBag()->add('success', new TranslatableMessage('saved_export.Export is deleted'));
}
$this->requestStack->getSession()->getFlashBag()->add('success', $this->translator->trans('saved_export.Export is deleted'));
return new RedirectResponse(
@@ -70,6 +87,105 @@ class SavedExportController
);
}
#[Route(path: '/exports/saved/create-from-export-generation/{id}/new', name: 'chill_main_export_saved_create_from_export_generation')]
public function createFromExportGeneration(ExportGeneration $exportGeneration, Request $request): Response
{
if (!$this->security->isGranted(ExportGenerationVoter::VIEW, $exportGeneration)) {
throw new AccessDeniedHttpException();
}
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('only regular user can create a saved export');
}
$export = $this->exportManager->getExport($exportGeneration->getExportAlias());
$title = $export->getTitle() instanceof TranslatableInterface ? $export->getTitle()->trans($this->translator) :
$this->translator->trans($export->getTitle());
$savedExport = new SavedExport();
$savedExport
->setExportAlias($exportGeneration->getExportAlias())
->setUser($user)
->setOptions($exportGeneration->getOptions())
->setTitle(
$request->query->has('title') ? $request->query->get('title') : $title
);
if ($exportGeneration->isLinkedToSavedExport()) {
$savedExport->setDescription($exportGeneration->getSavedExport()->getDescription());
} else {
$savedExport->setDescription(
implode(
"\n",
array_map(
fn (string $item) => '- '.$item."\n",
$this->exportDescriptionHelper->describe($savedExport->getExportAlias(), $savedExport->getOptions(), includeExportTitle: false)
)
)
);
}
return $this->handleEdit($savedExport, $request, true);
}
#[Route(path: '/exports/saved/duplicate-from-saved-export/{id}/new', name: 'chill_main_export_saved_duplicate')]
public function duplicate(SavedExport $previousSavedExport, Request $request): Response
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('only regular user can create a saved export');
}
if (!$this->security->isGranted(SavedExportVoter::EDIT, $previousSavedExport)) {
throw new AccessDeniedHttpException('Not allowed to edit this saved export');
}
$savedExport = new SavedExport();
$savedExport
->setExportAlias($previousSavedExport->getExportAlias())
->setUser($user)
->setOptions($previousSavedExport->getOptions())
->setDescription($previousSavedExport->getDescription())
->setTitle(
$request->query->has('title') ?
$request->query->get('title') :
$previousSavedExport->getTitle().' ('.$this->translator->trans('saved_export.Duplicated').' '.(new \DateTimeImmutable('now'))->format('d-m-Y H:i:s').')'
);
return $this->handleEdit($savedExport, $request);
}
private function handleEdit(SavedExport $savedExport, Request $request, bool $showWarningAutoGeneratedDescription = false): Response
{
$form = $this->formFactory->create(SavedExportType::class, $savedExport);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->persist($savedExport);
$this->entityManager->flush();
if (($session = $request->getSession()) instanceof Session) {
$session->getFlashBag()->add('success', new TranslatableMessage('saved_export.Saved export is saved!'));
}
return new RedirectResponse(
$this->urlGenerator->generate('chill_main_export_saved_list_my'),
);
}
return new Response(
$this->templating->render(
'@ChillMain/SavedExport/new.html.twig',
[
'form' => $form->createView(),
'showWarningAutoGeneratedDescription' => $showWarningAutoGeneratedDescription,
],
),
);
}
#[\Symfony\Component\Routing\Attribute\Route(path: '/{_locale}/exports/saved/{id}/edit', name: 'chill_main_export_saved_edit')]
public function edit(#[MapEntity(id: 'id')] SavedExport $savedExport, Request $request): Response
{
@@ -87,7 +203,7 @@ class SavedExportController
$this->requestStack->getSession()->getFlashBag()->add('success', $this->translator->trans('saved_export.Saved export is saved!'));
return new RedirectResponse(
$this->urlGenerator->generate('chill_main_export_saved_list_my')
$this->urlGenerator->generate('chill_main_export_saved_list_my'),
);
}
@@ -96,45 +212,37 @@ class SavedExportController
'@ChillMain/SavedExport/edit.html.twig',
[
'form' => $form->createView(),
]
)
],
),
);
}
#[\Symfony\Component\Routing\Attribute\Route(path: '/{_locale}/exports/saved/my', name: 'chill_main_export_saved_list_my')]
public function list(): Response
#[\Symfony\Component\Routing\Attribute\Route(path: '/{_locale}/exports/saved/{savedExport}/edit-options/{exportGeneration}', name: 'chill_main_export_saved_options_edit')]
public function updateOptionsFromGeneration(SavedExport $savedExport, ExportGeneration $exportGeneration, Request $request): Response
{
$user = $this->security->getUser();
if (!$this->security->isGranted('ROLE_USER') || !$user instanceof User) {
throw new AccessDeniedHttpException();
if (!$this->security->isGranted(SavedExportVoter::DUPLICATE, $savedExport)) {
throw new AccessDeniedHttpException('You are not allowed to access this saved export');
}
$exports = $this->savedExportRepository->findByUser($user, ['title' => 'ASC']);
// group by center
/** @var array<string, array{saved: SavedExport, export: ExportInterface}> $exportsGrouped */
$exportsGrouped = [];
foreach ($exports as $savedExport) {
$export = $this->exportManager->getExport($savedExport->getExportAlias());
$exportsGrouped[
$export instanceof GroupedExportInterface
? $this->translator->trans($export->getGroup()) : '_'
][] = ['saved' => $savedExport, 'export' => $export];
if (!$this->security->isGranted(ExportGenerationVoter::VIEW, $exportGeneration)) {
throw new AccessDeniedHttpException('You are not allowed to access this export generation');
}
ksort($exportsGrouped);
if ($savedExport->getExportAlias() !== $exportGeneration->getExportAlias()) {
throw new UnprocessableEntityHttpException('export alias does not match');
}
return new Response(
$this->templating->render(
'@ChillMain/SavedExport/index.html.twig',
[
'grouped_exports' => $exportsGrouped,
'total' => \count($exports),
]
)
$savedExport->setOptions($exportGeneration->getOptions());
$this->entityManager->flush();
$session = $request->getSession();
if ($session instanceof Session) {
$session->getFlashBag()->add('success', new TranslatableMessage('saved_export.Options updated successfully'));
}
return new RedirectResponse(
$this->urlGenerator->generate('chill_main_export_saved_edit', ['id' => $savedExport->getId()]),
);
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Export\GroupedExportInterface;
use Chill\MainBundle\Repository\ExportGenerationRepository;
use Chill\MainBundle\Repository\SavedExportRepositoryInterface;
use Chill\MainBundle\Security\Authorization\ChillExportVoter;
use Chill\MainBundle\Security\Authorization\SavedExportVoter;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class SavedExportIndexController
{
public function __construct(
private \Twig\Environment $templating,
private ExportManager $exportManager,
private SavedExportRepositoryInterface $savedExportRepository,
private Security $security,
private TranslatorInterface $translator,
private ExportGenerationRepository $exportGenerationRepository,
private FilterOrderHelperFactoryInterface $filterOrderHelperFactory,
) {}
#[Route(path: '/{_locale}/exports/saved/my', name: 'chill_main_export_saved_list_my')]
public function list(): Response
{
$user = $this->security->getUser();
if (!$this->security->isGranted(ChillExportVoter::GENERATE_SAVED_EXPORT) || !$user instanceof User) {
throw new AccessDeniedHttpException(sprintf('Missing role: %s', ChillExportVoter::GENERATE_SAVED_EXPORT));
}
$filter = $this->buildFilter();
$filterParams = [];
if ('' !== $filter->getQueryString() && null !== $filter->getQueryString()) {
$filterParams[SavedExportRepositoryInterface::FILTER_DESCRIPTION | SavedExportRepositoryInterface::FILTER_TITLE] = $filter->getQueryString();
}
$exports = array_filter(
$this->savedExportRepository->findSharedWithUser($user, ['exportAlias' => 'ASC', 'title' => 'ASC'], filters: $filterParams),
fn (SavedExport $savedExport): bool => $this->security->isGranted(SavedExportVoter::GENERATE, $savedExport),
);
// group by center
/** @var array<string, array{saved: SavedExport, export: ExportInterface}> $exportsGrouped */
$exportsGrouped = [];
foreach ($exports as $savedExport) {
$export = $this->exportManager->getExport($savedExport->getExportAlias());
$exportsGrouped[$export instanceof GroupedExportInterface
? $this->translator->trans($export->getGroup()) : '_'][] = ['saved' => $savedExport, 'export' => $export];
}
ksort($exportsGrouped);
// get last executions
$lastExecutions = [];
foreach ($exports as $savedExport) {
$lastExecutions[$savedExport->getId()->toString()] = $this->exportGenerationRepository
->findExportGenerationBySavedExportAndUser($savedExport, $user, 5);
}
return new Response(
$this->templating->render(
'@ChillMain/SavedExport/index.html.twig',
[
'grouped_exports' => $exportsGrouped,
'total' => \count($exports),
'last_executions' => $lastExecutions,
'filter' => $filter,
],
),
);
}
private function buildFilter(): FilterOrderHelper
{
$filter = $this->filterOrderHelperFactory->create('saved-export-index-filter');
$filter->addSearchBox();
return $filter->build();
}
}

View File

@@ -11,14 +11,11 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Form\UserPhonenumberType;
use Chill\MainBundle\Form\UserProfileType;
use Chill\MainBundle\Security\ChillSecurity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Component\Routing\Annotation\Route;
@@ -41,16 +38,19 @@ final class UserProfileController extends AbstractController
}
$user = $this->security->getUser();
$editForm = $this->createPhonenumberEditForm($user);
$editForm = $this->createForm(UserProfileType::class, $user);
$editForm->get('notificationFlags')->setData($user->getNotificationFlags());
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
$phonenumber = $editForm->get('phonenumber')->getData();
$notificationFlagsData = $editForm->get('notificationFlags')->getData();
$user->setNotificationFlags($notificationFlagsData);
$user->setPhonenumber($phonenumber);
$this->managerRegistry->getManager()->flush();
$this->addFlash('success', $this->translator->trans('user.profile.Phonenumber successfully updated!'));
$em = $this->managerRegistry->getManager();
$em->flush();
$this->addFlash('success', $this->translator->trans('user.profile.Profile successfully updated!'));
return $this->redirectToRoute('chill_main_user_profile');
}
@@ -60,13 +60,4 @@ final class UserProfileController extends AbstractController
'form' => $editForm,
]);
}
private function createPhonenumberEditForm(UserInterface $user): FormInterface
{
return $this->createForm(
UserPhonenumberType::class,
$user,
)
->add('submit', SubmitType::class, ['label' => $this->translator->trans('Save')]);
}
}

View File

@@ -11,7 +11,10 @@ declare(strict_types=1);
namespace Chill\MainBundle\DataFixtures\ORM;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\GroupCenter;
use Chill\MainBundle\Entity\PermissionsGroup;
use Chill\MainBundle\Entity\RoleScope;
use Chill\MainBundle\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
@@ -57,6 +60,15 @@ class LoadUsers extends Fixture implements OrderedFixtureInterface
public function load(ObjectManager $manager): void
{
$roleScope = new RoleScope();
$roleScope->setRole('CHILL_MAIN_COMPOSE_EXPORT');
$permissionGroup = new PermissionsGroup();
$permissionGroup->setName('export');
$permissionGroup->addRoleScope($roleScope);
$manager->persist($roleScope);
$manager->persist($permissionGroup);
foreach (self::$refs as $username => $params) {
$user = new User();
@@ -76,7 +88,14 @@ class LoadUsers extends Fixture implements OrderedFixtureInterface
->setEmail(sprintf('%s@chill.social', \str_replace(' ', '', (string) $username)));
foreach ($params['groupCenterRefs'] as $groupCenterRef) {
$user->addGroupCenter($this->getReference($groupCenterRef, GroupCenter::class));
$user->addGroupCenter($gc = $this->getReference($groupCenterRef, GroupCenter::class));
$exportGroupCenter = new GroupCenter();
$exportGroupCenter->setPermissionsGroup($permissionGroup);
$exportGroupCenter->setCenter($gc->getCenter());
$manager->persist($exportGroupCenter);
$user->addGroupCenter($exportGroupCenter);
}
echo 'Creating user '.$username."... \n";

View File

@@ -78,6 +78,7 @@ use Chill\MainBundle\Form\RegroupmentType;
use Chill\MainBundle\Form\UserGroupType;
use Chill\MainBundle\Form\UserJobType;
use Chill\MainBundle\Form\UserType;
use Chill\MainBundle\Security\Authorization\ChillExportVoter;
use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType;
use Ramsey\Uuid\Doctrine\UuidType;
use Symfony\Component\Config\FileLocator;
@@ -332,6 +333,9 @@ class ChillMainExtension extends Extension implements
'strategy' => 'unanimous',
'allow_if_all_abstain' => false,
],
'role_hierarchy' => [
ChillExportVoter::COMPOSE_EXPORT => ChillExportVoter::GENERATE_SAVED_EXPORT,
],
]);
// add crud api

View File

@@ -1,102 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\DependencyInjection\CompilerPass;
use Chill\MainBundle\Export\ExportManager;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
/**
* Compiles the services tagged with :.
*
* - chill.export
* - chill.export_formatter
* - chill.export_aggregator
* - chill.export_filter
* - chill.export_elements_provider
*/
class ExportsCompilerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->has(ExportManager::class)) {
throw new \LogicException('service '.ExportManager::class.' is not defined. It is required by ExportsCompilerPass');
}
$chillManagerDefinition = $container->findDefinition(
ExportManager::class
);
$this->compileFormatters($chillManagerDefinition, $container);
$this->compileExportElementsProvider($chillManagerDefinition, $container);
}
private function compileExportElementsProvider(
Definition $chillManagerDefinition,
ContainerBuilder $container,
): void {
$taggedServices = $container->findTaggedServiceIds(
'chill.export_elements_provider'
);
$knownAliases = [];
foreach ($taggedServices as $id => $tagAttributes) {
foreach ($tagAttributes as $attributes) {
if (!isset($attributes['prefix'])) {
throw new \LogicException("the 'prefix' attribute is missing in your service '{$id}' definition");
}
if (array_search($attributes['prefix'], $knownAliases, true)) {
throw new \LogicException('There is already a chill.export_elements_provider service with prefix '.$attributes['prefix'].'. Choose another prefix.');
}
$knownAliases[] = $attributes['prefix'];
$chillManagerDefinition->addMethodCall(
'addExportElementsProvider',
[new Reference($id), $attributes['prefix']]
);
}
}
}
private function compileFormatters(
Definition $chillManagerDefinition,
ContainerBuilder $container,
): void {
$taggedServices = $container->findTaggedServiceIds(
'chill.export_formatter'
);
$knownAliases = [];
foreach ($taggedServices as $id => $tagAttributes) {
foreach ($tagAttributes as $attributes) {
if (!isset($attributes['alias'])) {
throw new \LogicException("the 'alias' attribute is missing in your service '{$id}' definition");
}
if (array_search($attributes['alias'], $knownAliases, true)) {
throw new \LogicException('There is already a chill.export_formatter service with alias '.$attributes['alias'].'. Choose another alias.');
}
$knownAliases[] = $attributes['alias'];
$chillManagerDefinition->addMethodCall(
'addFormatter',
[new Reference($id), $attributes['alias']]
);
}
}
}
}

View File

@@ -23,7 +23,7 @@ class Unaccent extends FunctionNode
{
private ?\Doctrine\ORM\Query\AST\Node $string = null;
public function getSql(\Doctrine\ORM\Query\SqlWalker $sqlWalker)
public function getSql(\Doctrine\ORM\Query\SqlWalker $sqlWalker): string
{
return 'UNACCENT('.$this->string->dispatch($sqlWalker).')';
}

View File

@@ -63,4 +63,28 @@ class PrivateCommentEmbeddable
return $this;
}
/**
* Merges comments from the provided object into the current object.
*
* Identifies common user IDs between the current object's comments and the
* newComment's comments. If a user ID exists in both, their comments are
* concatenated with the provided separator. If a user ID exists only in the
* newComment, their comment is added to the current object directly.
*
* @param self $commentsToAppend the object containing the new comments to be merged
* @param string $separator the string used to separate concatenated comments
*/
public function concatenateComments(self $commentsToAppend, string $separator = "\n\n-----------\n\n"): void
{
$commonUserIds = array_intersect(array_keys($this->comments), array_keys($commentsToAppend->getComments()));
foreach ($commentsToAppend->getComments() as $userId => $comment) {
if (in_array($userId, $commonUserIds, true)) {
$this->comments[$userId] = $this->comments[$userId].$separator.$commentsToAppend->getComments()[$userId];
} else {
$this->comments[$userId] = $commentsToAppend->getComments()[$userId];
}
}
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Entity;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Symfony\Component\Serializer\Annotation as Serializer;
/**
* Contains the single execution of an export.
*
* Attached to a stored object, which will contains the result of the execution. The status of the stored object
* is the status of the export generation.
*
* Generated exports should be deleted after a certain time, given by the column `deletedAt`. The stored object can be
* deleted after the export generation removal.
*/
#[ORM\Entity()]
#[ORM\Table('chill_main_export_generation')]
#[Serializer\DiscriminatorMap('type', ['export_generation' => ExportGeneration::class])]
class ExportGeneration implements TrackCreationInterface
{
use TrackCreationTrait;
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
#[Serializer\Groups(['read'])]
private UuidInterface $id;
#[ORM\ManyToOne(targetEntity: StoredObject::class, cascade: ['persist', 'refresh'])]
#[ORM\JoinColumn(nullable: false)]
#[Serializer\Groups(['read'])]
private StoredObject $storedObject;
public function __construct(
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
#[Serializer\Groups(['read'])]
private string $exportAlias,
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])]
private array $options = [],
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $deleteAt = null,
/**
* The related saved export.
*
* Note that, in some case, the options of this ExportGeneration are not equals to the options of the saved export.
* This happens when the options of the saved export are updated.
*/
#[ORM\ManyToOne(targetEntity: SavedExport::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?SavedExport $savedExport = null,
) {
$this->id = Uuid::uuid4();
$this->storedObject = new StoredObject(StoredObject::STATUS_PENDING);
}
public function setDeleteAt(?\DateTimeImmutable $deleteAt): ExportGeneration
{
$this->deleteAt = $deleteAt;
return $this;
}
public function getDeleteAt(): ?\DateTimeImmutable
{
return $this->deleteAt;
}
public function getId(): UuidInterface
{
return $this->id;
}
public function getStoredObject(): StoredObject
{
return $this->storedObject;
}
public function getExportAlias(): string
{
return $this->exportAlias;
}
public function getOptions(): array
{
return $this->options;
}
public function getSavedExport(): ?SavedExport
{
return $this->savedExport;
}
#[Serializer\Groups(['read'])]
#[Serializer\SerializedName('status')]
public function getStatus(): string
{
return $this->getStoredObject()->getStatus();
}
public function setSavedExport(SavedExport $savedExport): self
{
$this->savedExport = $savedExport;
return $this;
}
public function isLinkedToSavedExport(): bool
{
return null !== $this->savedExport;
}
/**
* Compares the options of the saved export and the current export generation.
*
* Return false if the current export generation's options are not equal to the one in the saved export. This may
* happens when we update the configuration of a saved export.
*/
public function isConfigurationDifferentFromSavedExport(): bool
{
if (!$this->isLinkedToSavedExport()) {
return false;
}
return $this->savedExport->getOptions() !== $this->getOptions();
}
public static function fromSavedExport(SavedExport $savedExport, ?\DateTimeImmutable $deletedAt = null, ?array $overrideOptions = null): self
{
$generation = new self($savedExport->getExportAlias(), $overrideOptions ?? $savedExport->getOptions(), $deletedAt, $savedExport);
$generation->getStoredObject()->setTitle($savedExport->getTitle());
return $generation;
}
}

View File

@@ -50,4 +50,9 @@ class SimpleGeographicalUnitDTO
#[Serializer\Groups(['read'])]
public int $layerId,
) {}
public function getId(): int
{
return $this->id;
}
}

View File

@@ -15,6 +15,7 @@ use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -22,10 +23,11 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'chill_main_notification')]
#[ORM\Index(name: 'chill_main_notification_related_entity_idx', columns: ['relatedentityclass', 'relatedentityid'])]
#[ORM\Index(columns: ['relatedentityclass', 'relatedentityid'], name: 'chill_main_notification_related_entity_idx')]
class Notification implements TrackUpdateInterface
{
use TrackUpdateTrait;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false)]
private string $accessKey;
@@ -38,12 +40,19 @@ class Notification implements TrackUpdateInterface
#[ORM\JoinTable(name: 'chill_main_notification_addresses_user')]
private Collection $addressees;
/**
* @var Collection<int, UserGroup>
*/
#[ORM\ManyToMany(targetEntity: UserGroup::class)]
#[ORM\JoinTable(name: 'chill_main_notification_addressee_user_group')]
private Collection $addresseeUserGroups;
/**
* a list of destinee which will receive notifications.
*
* @var array|string[]
*/
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, options: ['default' => '[]', 'jsonb' => true])]
#[ORM\Column(type: Types::JSON, options: ['default' => '[]', 'jsonb' => true])]
private array $addressesEmails = [];
/**
@@ -62,21 +71,21 @@ class Notification implements TrackUpdateInterface
#[ORM\OrderBy(['createdAt' => \Doctrine\Common\Collections\Criteria::ASC])]
private Collection $comments;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE)]
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $date;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
#[ORM\Column(type: Types::INTEGER)]
private ?int $id = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)]
#[ORM\Column(type: Types::TEXT)]
private string $message = '';
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255)]
#[ORM\Column(type: Types::STRING, length: 255)]
private string $relatedEntityClass = '';
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
#[ORM\Column(type: Types::INTEGER)]
private int $relatedEntityId;
private array $removedAddresses = [];
@@ -86,7 +95,7 @@ class Notification implements TrackUpdateInterface
private ?User $sender = null;
#[Assert\NotBlank(message: 'notification.Title must be defined')]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => ''])]
#[ORM\Column(type: Types::TEXT, options: ['default' => ''])]
private string $title = '';
/**
@@ -96,26 +105,47 @@ class Notification implements TrackUpdateInterface
#[ORM\JoinTable(name: 'chill_main_notification_addresses_unread')]
private Collection $unreadBy;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\ManyToOne(targetEntity: User::class)]
private ?User $updatedBy = null;
#[ORM\Column(name: 'type', type: Types::STRING, nullable: true)]
private string $type = '';
public function __construct()
{
$this->addressees = new ArrayCollection();
$this->addresseeUserGroups = new ArrayCollection();
$this->unreadBy = new ArrayCollection();
$this->comments = new ArrayCollection();
$this->setDate(new \DateTimeImmutable());
$this->accessKey = bin2hex(openssl_random_pseudo_bytes(24));
}
public function addAddressee(User $addressee): self
public function addAddressee(User|UserGroup $addressee): self
{
if (!$this->addressees->contains($addressee)) {
$this->addressees[] = $addressee;
$this->addedAddresses[] = $addressee;
if ($addressee instanceof User) {
if (!$this->addressees->contains($addressee)) {
$this->addressees->add($addressee);
$this->addedAddresses[] = $addressee;
}
return $this;
}
if (!$this->addresseeUserGroups->contains($addressee)) {
$this->addresseeUserGroups->add($addressee);
}
return $this;
}
public function addAddressesEmail(string $email): void
/**
* @deprecated
*/
public function addAddressesEmail(string $email)
{
if (!\in_array($email, $this->addressesEmails, true)) {
$this->addressesEmails[] = $email;
@@ -148,13 +178,23 @@ class Notification implements TrackUpdateInterface
#[Assert\Callback]
public function assertCountAddresses(ExecutionContextInterface $context, $payload): void
{
if (0 === (\count($this->getAddressesEmails()) + \count($this->getAddressees()))) {
if (0 === (\count($this->getAddresseeUserGroups()) + \count($this->getAddressees()))) {
$context->buildViolation('notification.At least one addressee')
->atPath('addressees')
->addViolation();
}
}
public function getAddresseeUserGroups(): Collection
{
return $this->addresseeUserGroups;
}
public function setAddresseeUserGroups(Collection $addresseeUserGroups): void
{
$this->addresseeUserGroups = $addresseeUserGroups;
}
public function getAccessKey(): string
{
return $this->accessKey;
@@ -178,6 +218,23 @@ class Notification implements TrackUpdateInterface
return $this->addressees;
}
public function getAllAddressees(): array
{
$allUsers = [];
foreach ($this->getAddressees() as $user) {
$allUsers[$user->getId()] = $user;
}
foreach ($this->getAddresseeUserGroups() as $userGroup) {
foreach ($userGroup->getUsers() as $user) {
$allUsers[$user->getId()] = $user;
}
}
return array_values($allUsers);
}
/**
* @return array|string[]
*/
@@ -289,12 +346,18 @@ class Notification implements TrackUpdateInterface
$this->addressesOnLoad = null;
}
public function removeAddressee(User $addressee): self
public function removeAddressee(User|UserGroup $addressee): self
{
if ($this->addressees->removeElement($addressee)) {
$this->removedAddresses[] = $addressee;
if ($addressee instanceof User) {
if ($this->addressees->contains($addressee)) {
$this->addressees->removeElement($addressee);
return $this;
}
}
$this->addresseeUserGroups->removeElement($addressee);
return $this;
}
@@ -361,4 +424,30 @@ class Notification implements TrackUpdateInterface
return $this;
}
public function setUpdatedAt(\DateTimeInterface $datetime): self
{
$this->updatedAt = \DateTimeImmutable::createFromInterface($datetime);
return $this;
}
public function setUpdatedBy(User $user): self
{
$this->updatedBy = $user;
return $this;
}
public function setType(string $type): self
{
$this->type = $type;
return $this;
}
public function getType(): string
{
return $this->type;
}
}

View File

@@ -102,4 +102,22 @@ class Regroupment
return $this;
}
/**
* Return true if the given center is contained into this regroupment.
*/
public function containsCenter(Center $center): bool
{
return $this->centers->contains($center);
}
/**
* Return true if at least one of the given centers is contained into this regroupment.
*
* @param list<Center> $centers
*/
public function containsAtLeastOneCenter(array $centers): bool
{
return array_reduce($centers, fn (bool $carry, Center $center) => $carry || $this->containsCenter($center), false);
}
}

View File

@@ -15,6 +15,9 @@ use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
@@ -50,9 +53,25 @@ class SavedExport implements TrackCreationInterface, TrackUpdateInterface
#[ORM\ManyToOne(targetEntity: User::class)]
private User $user;
/**
* @var Collection<int, User>
*/
#[ORM\ManyToMany(targetEntity: User::class)]
#[ORM\JoinTable(name: 'chill_main_saved_export_users')]
private Collection $sharedWithUsers;
/**
* @var Collection<int, UserGroup>
*/
#[ORM\ManyToMany(targetEntity: UserGroup::class)]
#[ORM\JoinTable(name: 'chill_main_saved_export_usergroups')]
private Collection $sharedWithGroups;
public function __construct()
{
$this->id = Uuid::uuid4();
$this->sharedWithUsers = new ArrayCollection();
$this->sharedWithGroups = new ArrayCollection();
}
public function getDescription(): string
@@ -119,4 +138,71 @@ class SavedExport implements TrackCreationInterface, TrackUpdateInterface
return $this;
}
public function addShare(User|UserGroup $shareUser): SavedExport
{
if ($shareUser instanceof User) {
if (!$this->sharedWithUsers->contains($shareUser)) {
$this->sharedWithUsers->add($shareUser);
}
} else {
if (!$this->sharedWithGroups->contains($shareUser)) {
$this->sharedWithGroups->add($shareUser);
}
}
return $this;
}
public function removeShare(User|UserGroup $shareUser): SavedExport
{
if ($shareUser instanceof User) {
$this->sharedWithUsers->removeElement($shareUser);
} else {
$this->sharedWithGroups->removeElement($shareUser);
}
return $this;
}
/**
* @return ReadableCollection<int, User|UserGroup>
*/
public function getShare(): ReadableCollection
{
return new ArrayCollection([
...$this->sharedWithUsers->toArray(),
...$this->sharedWithGroups->toArray(),
]);
}
/**
* Return true if shared with at least one user or one group.
*/
public function isShared(): bool
{
return $this->sharedWithUsers->count() > 0 || $this->sharedWithGroups->count() > 0;
}
/**
* Determines if the user is shared with either directly or through a group.
*
* @param User $user the user to check
*
* @return bool returns true if the user is shared with directly or via group, otherwise false
*/
public function isSharedWithUser(User $user): bool
{
if ($this->sharedWithUsers->contains($user)) {
return true;
}
foreach ($this->sharedWithGroups as $group) {
if ($group->contains($user)) {
return true;
}
}
return false;
}
}

View File

@@ -34,6 +34,9 @@ use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
#[ORM\Table(name: 'users')]
class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInterface
{
public const NOTIF_FLAG_IMMEDIATE_EMAIL = 'immediate-email';
public const NOTIF_FLAG_DAILY_DIGEST = 'daily-digest';
#[ORM\Id]
#[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)]
#[ORM\GeneratedValue(strategy: 'AUTO')]
@@ -116,6 +119,12 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
#[PhonenumberConstraint]
private ?PhoneNumber $phonenumber = null;
/**
* @var array<string, list<string>>
*/
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])]
private array $notificationFlags = [];
/**
* User constructor.
*/
@@ -613,4 +622,57 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
return $this;
}
/**
* Check if the current object is an instance of User.
*
* @return bool returns true if the current object is an instance of User, false otherwise
*/
public function isUser(): bool
{
return true;
}
public function getNotificationFlags(): array
{
return $this->notificationFlags;
}
public function setNotificationFlags(array $notificationFlags)
{
$this->notificationFlags = $notificationFlags;
}
public function getNotificationFlagData(string $flag): array
{
return $this->notificationFlags[$flag] ?? [];
}
public function setNotificationFlagData(string $flag, array $data): void
{
$this->notificationFlags[$flag] = $data;
}
public function isNotificationSendImmediately(string $type): bool
{
if ([] === $this->getNotificationFlagData($type) || in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $this->getNotificationFlagData($type), true)) {
return true;
}
return false;
}
public function isNotificationDailyDigest(string $type): bool
{
if (in_array(User::NOTIF_FLAG_DAILY_DIGEST, $this->getNotificationFlagData($type), true)) {
return true;
}
return false;
}
public function getLocale(): string
{
return 'fr';
}
}

View File

@@ -21,6 +21,19 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Represents a user group entity in the system.
*
* This class is used for managing user groups, including their relationships
* with users, administrative users, and additional metadata such as colors and labels.
*
* Groups may be configured to have mutual exclusion properties based on an
* exclusion key. This ensures that groups sharing the same key cannot coexist
* in certain relationship contexts.
*
* Groups may be related to a UserJob. In that case, a cronjob task ensure that the members of the groups are
* automatically synced with this group. Such groups are also automatically created by the cronjob.
*/
#[ORM\Entity]
#[ORM\Table(name: 'chill_main_user_group')]
// this discriminator key is required for automated denormalization
@@ -71,6 +84,13 @@ class UserGroup
#[Assert\Email]
private string $email = '';
/**
* UserJob to which the group is related.
*/
#[ORM\ManyToOne(targetEntity: UserJob::class)]
#[ORM\JoinColumn(nullable: true)]
private ?UserJob $userJob = null;
public function __construct()
{
$this->adminUsers = new ArrayCollection();
@@ -209,6 +229,21 @@ class UserGroup
return '' !== $this->email;
}
public function hasUserJob(): bool
{
return null !== $this->userJob;
}
public function getUserJob(): ?UserJob
{
return $this->userJob;
}
public function setUserJob(?UserJob $userJob): void
{
$this->userJob = $userJob;
}
/**
* Checks if the current object is an instance of the UserGroup class.
*

View File

@@ -12,25 +12,42 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Contracts\Translation\TranslatableInterface;
/**
* Interface for Aggregators.
*
* Aggregators gather result of a query. Most of the time, it will add
* a GROUP BY clause.
*
* @template D of array
*/
interface AggregatorInterface extends ModifierInterface
{
/**
* Add a form to collect data from the user.
*/
public function buildForm(FormBuilderInterface $builder);
public function buildForm(FormBuilderInterface $builder): void;
/**
* Get the default data, that can be use as "data" for the form.
*
* @return D
*/
public function getFormDefaultData(): array;
/**
* @param D $formData
*/
public function normalizeFormData(array $formData): array;
/**
* @return D
*/
public function denormalizeFormData(array $formData, int $fromVersion): array;
public function getNormalizationVersion(): int;
/**
* get a callable which will be able to transform the results into
* viewable and understable string.
@@ -74,9 +91,9 @@ interface AggregatorInterface extends ModifierInterface
* @param string $key The column key, as added in the query
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
*
* @return \Closure where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
* @return callable(mixed $value): (string|int|\DateTimeInterface|TranslatableInterface) where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
*/
public function getLabels($key, array $values, mixed $data);
public function getLabels(string $key, array $values, mixed $data): callable;
/**
* give the list of keys the current export added to the queryBuilder in
@@ -85,7 +102,9 @@ interface AggregatorInterface extends ModifierInterface
* Example: if your query builder will contains `SELECT count(id) AS count_id ...`,
* this function will return `array('count_id')`.
*
* @param mixed[] $data the data from the export's form (added by self::buildForm)
* @param D $data the data from the export's form (added by self::buildForm)
*
* @return list<string>
*/
public function getQueryKeys($data);
public function getQueryKeys(array $data): array;
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export\Cronjob;
use Chill\MainBundle\Cron\CronJobInterface;
use Chill\MainBundle\Entity\CronJobExecution;
use Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessage;
use Chill\MainBundle\Repository\ExportGenerationRepository;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
final readonly class RemoveExpiredExportGenerationCronJob implements CronJobInterface
{
public const KEY = 'remove-expired-export-generation';
public function __construct(private ClockInterface $clock, private ExportGenerationRepository $exportGenerationRepository, private MessageBusInterface $messageBus) {}
public function canRun(?CronJobExecution $cronJobExecution): bool
{
if (null === $cronJobExecution) {
return true;
}
return $cronJobExecution->getLastStart()->getTimestamp() < $this->clock->now()->sub(new \DateInterval('PT24H'))->getTimestamp();
}
public function getKey(): string
{
return self::KEY;
}
public function run(array $lastExecutionData): ?array
{
$now = $this->clock->now();
foreach ($this->exportGenerationRepository->findExpiredExportGeneration($now) as $exportGeneration) {
$this->messageBus->dispatch(new Envelope(new RemoveExportGenerationMessage($exportGeneration)));
}
return ['last-deletion' => $now->getTimestamp()];
}
}

View File

@@ -28,8 +28,16 @@ interface DirectExportInterface extends ExportElementInterface
/**
* Generate the export.
*
* @return FormattedExportGeneration
*/
public function generate(array $acl, array $data = []): Response;
public function generate(array $acl, array $data, ExportGenerationContext $context): Response|FormattedExportGeneration;
public function normalizeFormData(array $formData): array;
public function denormalizeFormData(array $formData, int $fromVersion): array;
public function getNormalizationVersion(): int;
/**
* get a description, which will be used in UI (and translated).

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export\Exception;
class ExportGenerationException extends ExportRuntimeException {}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export\Exception;
class ExportLogicException extends \LogicException {}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export\Exception;
class ExportRuntimeException extends \RuntimeException {}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export\Exception;
class UnauthorizedGenerationException extends ExportGenerationException
{
public function __construct(string $message, ?\Throwable $previous = null)
{
parent::__construct($message, previous: $previous);
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Regroupment;
use Chill\MainBundle\Form\Type\Export\AggregatorType;
use Chill\MainBundle\Form\Type\Export\ExportType;
use Chill\MainBundle\Form\Type\Export\FilterType;
use Chill\MainBundle\Repository\CenterRepositoryInterface;
use Chill\MainBundle\Repository\RegroupmentRepositoryInterface;
/**
* @phpstan-type NormalizedData array{centers: array{centers: list<int>, regroupments: list<int>}, export: array{form: array<string, mixed>, version: int}, filters: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, aggregators: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, pick_formatter: string, formatter: array{form: array<string, mixed>, version: int}}
*/
class ExportConfigNormalizer
{
public function __construct(
private readonly ExportManager $exportManager,
private readonly CenterRepositoryInterface $centerRepository,
private readonly RegroupmentRepositoryInterface $regroupmentRepository,
) {}
/**
* @return NormalizedData
*/
public function normalizeConfig(string $exportAlias, array $formData): array
{
$exportData = $formData[ExportType::EXPORT_KEY];
$export = $this->exportManager->getExport($exportAlias);
$serialized = [
'export' => [
'form' => $export->normalizeFormData($exportData),
'version' => $export->getNormalizationVersion(),
],
];
$serialized['centers'] = [
'centers' => array_values(array_map(static fn (Center $center) => $center->getId(), $formData['centers']['centers'] ?? [])),
'regroupments' => array_values(array_map(static fn (Regroupment $group) => $group->getId(), $formData['centers']['regroupments'] ?? [])),
];
$filtersSerialized = [];
foreach ($formData[ExportType::FILTER_KEY] as $alias => $filterData) {
$filter = $this->exportManager->getFilter($alias);
$filtersSerialized[$alias][FilterType::ENABLED_FIELD] = (bool) $filterData[FilterType::ENABLED_FIELD];
if ($filterData[FilterType::ENABLED_FIELD]) {
$filtersSerialized[$alias]['form'] = $filter->normalizeFormData($filterData['form']);
$filtersSerialized[$alias]['version'] = $filter->getNormalizationVersion();
}
}
$serialized['filters'] = $filtersSerialized;
$aggregatorsSerialized = [];
foreach ($formData[ExportType::AGGREGATOR_KEY] as $alias => $aggregatorData) {
$aggregator = $this->exportManager->getAggregator($alias);
$aggregatorsSerialized[$alias][FilterType::ENABLED_FIELD] = (bool) $aggregatorData[AggregatorType::ENABLED_FIELD];
if ($aggregatorData[AggregatorType::ENABLED_FIELD]) {
$aggregatorsSerialized[$alias]['form'] = $aggregator->normalizeFormData($aggregatorData['form']);
$aggregatorsSerialized[$alias]['version'] = $aggregator->getNormalizationVersion();
}
}
$serialized['aggregators'] = $aggregatorsSerialized;
$serialized['pick_formatter'] = $formData['pick_formatter'];
$formatter = $this->exportManager->getFormatter($formData['pick_formatter']);
$serialized['formatter']['form'] = $formatter->normalizeFormData($formData['formatter']);
$serialized['formatter']['version'] = $formatter->getNormalizationVersion();
return $serialized;
}
/**
* @param NormalizedData $serializedData
* @param bool $replaceDisabledByDefaultData if true, when a filter is not enabled, the formDefaultData is set
*/
public function denormalizeConfig(string $exportAlias, array $serializedData, bool $replaceDisabledByDefaultData = false): array
{
$export = $this->exportManager->getExport($exportAlias);
$formater = $this->exportManager->getFormatter($serializedData['pick_formatter']);
$filtersConfig = [];
foreach ($serializedData['filters'] as $alias => $filterData) {
$aggregator = $this->exportManager->getFilter($alias);
$filtersConfig[$alias]['enabled'] = $filterData['enabled'];
if ($filterData['enabled']) {
$filtersConfig[$alias]['form'] = $aggregator->denormalizeFormData($filterData['form'], $filterData['version']);
} elseif ($replaceDisabledByDefaultData) {
$filtersConfig[$alias]['form'] = $aggregator->getFormDefaultData();
}
}
$aggregatorsConfig = [];
foreach ($serializedData['aggregators'] as $alias => $aggregatorData) {
$aggregator = $this->exportManager->getAggregator($alias);
$aggregatorsConfig[$alias]['enabled'] = $aggregatorData['enabled'];
if ($aggregatorData['enabled']) {
$aggregatorsConfig[$alias]['form'] = $aggregator->denormalizeFormData($aggregatorData['form'], $aggregatorData['version']);
} elseif ($replaceDisabledByDefaultData) {
$aggregatorsConfig[$alias]['form'] = $aggregator->getFormDefaultData();
}
}
return [
'export' => $export->denormalizeFormData($serializedData['export']['form'], $serializedData['export']['version']),
'filters' => $filtersConfig,
'aggregators' => $aggregatorsConfig,
'pick_formatter' => $serializedData['pick_formatter'],
'formatter' => $formater->denormalizeFormData($serializedData['formatter']['form'], $serializedData['formatter']['version']),
'centers' => [
'centers' => array_values(array_filter(array_map(fn (int $id) => $this->centerRepository->find($id), $serializedData['centers']['centers']), fn ($item) => null !== $item)),
'regroupments' => array_values(array_filter(array_map(fn (int $id) => $this->regroupmentRepository->find($id), $serializedData['centers']['regroupments']), fn ($item) => null !== $item)),
],
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export;
class ExportConfigProcessor
{
public function __construct(private readonly ExportManager $exportManager) {}
/**
* @return iterable<string, AggregatorInterface>
*/
public function retrieveUsedAggregators(mixed $data): iterable
{
if (null === $data) {
return [];
}
foreach ($data as $alias => $aggregatorData) {
if ($this->exportManager->hasAggregator($alias) && true === $aggregatorData['enabled']) {
yield $alias => $this->exportManager->getAggregator($alias);
}
}
}
/**
* @return iterable<string, FilterInterface>
*/
public function retrieveUsedFilters(mixed $data): iterable
{
if (null === $data) {
return [];
}
foreach ($data as $alias => $filterData) {
if ($this->exportManager->hasFilter($alias) && true === $filterData['enabled']) {
yield $alias => $this->exportManager->getFilter($alias);
}
}
}
}

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Doctrine\Common\Collections\Collection;
use Doctrine\Persistence\ObjectRepository;
/**
* Provides utilities for normalizing and denormalizing data entities and dates.
*/
trait ExportDataNormalizerTrait
{
/**
* Normalizes a Doctrine entity or a collection of entities to extract their identifiers.
*
* @param object|list<object>|null $entity the entity or collection of entities to normalize
*
* @return array|int|string Returns the identifier(s) of the entity or entities. If an array of entities is provided,
* an array of their identifiers is returned. If a single entity is provided, its identifier
* is returned. If null, returns an empty value.
*/
private function normalizeDoctrineEntity(object|array|null $entity): array|int|string
{
if (is_array($entity)) {
return array_values(array_filter(array_map(static fn (object $entity) => $entity->getId(), $entity), fn ($value) => null !== $value));
}
if ($entity instanceof Collection) {
return $this->normalizeDoctrineEntity($entity->toArray());
}
return $entity?->getId();
}
/**
* Denormalizes a Doctrine entity by fetching it from the provided repository based on the given ID(s).
*
* @param list<int>|int|string $id the identifier(s) of the entity to find
* @param ObjectRepository $repository the Doctrine repository to query
*
* @return object|array<object> the found entity or an array of entities if multiple IDs are provided
*
* @throws \UnexpectedValueException when the entity with the given ID does not exist
*/
private function denormalizeDoctrineEntity(array|int|string $id, ObjectRepository $repository): object|array
{
if (is_array($id)) {
if ([] === $id) {
return [];
}
return $repository->findBy(['id' => $id]);
}
if (null === $object = $repository->find($id)) {
throw new \UnexpectedValueException(sprintf('Object with id "%s" does not exist.', $id));
}
return $object;
}
/**
* Normalizer the "user or me" values.
*
* @param 'me'|User|iterable<'me'|User> $user
*
* @return int|'me'|list<'me'|int>
*/
private function normalizeUserOrMe(string|User|iterable $user): int|string|array
{
if (is_iterable($user)) {
$users = [];
foreach ($user as $u) {
$users[] = $this->normalizeUserOrMe($u);
}
return $users;
}
if ('me' === $user) {
return $user;
}
return $user->getId();
}
/**
* @param 'me'|int|iterable<'me'|int> $userId
*
* @return 'me'|User|array|null
*/
private function denormalizeUserOrMe(string|int|iterable $userId, UserRepositoryInterface $userRepository): string|User|array|null
{
if (is_iterable($userId)) {
$users = [];
foreach ($userId as $id) {
$users[] = $this->denormalizeUserOrMe($id, $userRepository);
}
return $users;
}
if ('me' === $userId) {
return 'me';
}
return $userRepository->find($userId);
}
/**
* @param 'me'|User|iterable<'me'|User> $user
*
* @return User|list<User>
*/
private function userOrMe(string|User|iterable $user, ExportGenerationContext $context): User|array
{
if (is_iterable($user)) {
$users = [];
foreach ($user as $u) {
$users[] = $this->userOrMe($u, $context);
}
return array_values(
array_filter($users, static fn (?User $user) => null !== $user)
);
}
if ('me' === $user) {
return $context->byUser;
}
return $user;
}
/**
* Normalizes a provided date into a specific string format.
*
* @param \DateTimeImmutable|\DateTime $date the date instance to normalize
*
* @return string a formatted string containing the type and formatted date
*/
private function normalizeDate(\DateTimeImmutable|\DateTime $date): string
{
return sprintf(
'%s,%s',
$date instanceof \DateTimeImmutable ? 'imm1' : 'mut1',
$date->format('d-m-Y-H:i:s.u e'),
);
}
/**
* Denormalizes a string back into a DateTime instance.
*
* The string is expected to contain a kind selector (e.g., 'imm1' or 'mut1')
* to determine the type of DateTime object (immutable or mutable) followed by a date format.
*
* @param string $date the string to be denormalized, containing the kind selector and formatted date
*
* @return \DateTimeImmutable|\DateTime a DateTime instance created from the given string
*
* @throws \UnexpectedValueException if the kind selector or date format is invalid
*/
private function denormalizeDate(string $date): \DateTimeImmutable|\DateTime
{
$format = 'd-m-Y-H:i:s.u e';
$denormalized = match (substr($date, 0, 4)) {
'imm1' => \DateTimeImmutable::createFromFormat($format, substr($date, 5)),
'mut1' => \DateTime::createFromFormat($format, substr($date, 5)),
default => throw new \UnexpectedValueException(sprintf('Unexpected format for the kind selector: %s', substr($date, 0, 4))),
};
if (false === $denormalized) {
throw new \UnexpectedValueException(sprintf('Unexpected date format: %s', substr($date, 5)));
}
return $denormalized;
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export;
use Chill\MainBundle\Entity\User;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Give an explanation of an export.
*/
final readonly class ExportDescriptionHelper
{
public function __construct(
private ExportManager $exportManager,
private ExportConfigNormalizer $exportConfigNormalizer,
private ExportConfigProcessor $exportConfigProcessor,
private TranslatorInterface $translator,
private Security $security,
) {}
/**
* @return list<string>
*/
public function describe(string $exportAlias, array $exportOptions, bool $includeExportTitle = true): array
{
$output = [];
$denormalized = $this->exportConfigNormalizer->denormalizeConfig($exportAlias, $exportOptions);
$user = $this->security->getUser();
if ($includeExportTitle) {
$output[] = $this->trans($this->exportManager->getExport($exportAlias)->getTitle());
}
if (!$user instanceof User) {
return $output;
}
$context = new ExportGenerationContext($user);
foreach ($this->exportConfigProcessor->retrieveUsedFilters($denormalized['filters']) as $name => $filter) {
$output[] = $this->trans($filter->describeAction($denormalized['filters'][$name]['form'], $context));
}
foreach ($this->exportConfigProcessor->retrieveUsedAggregators($denormalized['aggregators']) as $name => $aggregator) {
$output[] = $this->trans($aggregator->getTitle());
}
return $output;
}
private function trans(string|TranslatableInterface|array $translatable): string
{
if (is_string($translatable)) {
return $this->translator->trans($translatable);
}
if ($translatable instanceof TranslatableInterface) {
return $translatable->trans($this->translator);
}
// array case
return $this->translator->trans($translatable[0], $translatable[1] ?? []);
}
}

View File

@@ -11,6 +11,8 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Symfony\Contracts\Translation\TranslatableInterface;
/**
* The common methods between different object used to build export (i.e. : ExportInterface,
* FilterInterface, AggregatorInterface).
@@ -19,8 +21,6 @@ interface ExportElementInterface
{
/**
* get a title, which will be used in UI (and translated).
*
* @return string
*/
public function getTitle();
public function getTitle(): string|TranslatableInterface;
}

View File

@@ -31,5 +31,5 @@ interface ExportElementValidatedInterface
* validate the form's data and, if required, build a contraint
* violation on the data.
*/
public function validateForm(mixed $data, ExecutionContextInterface $context);
public function validateForm(mixed $data, ExecutionContextInterface $context): void;
}

View File

@@ -21,7 +21,7 @@ namespace Chill\MainBundle\Export;
interface ExportElementsProviderInterface
{
/**
* @return ExportElementInterface[]
* @return iterable<ExportElementInterface>
*/
public function getExportElements();
public function getExportElements(): iterable;
}

View File

@@ -11,27 +11,28 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Form\Type\Export\ExportType;
use Chill\MainBundle\Form\Type\Export\FilterType;
use Chill\MainBundle\Form\Type\Export\FormatterType;
use Chill\MainBundle\Form\Type\Export\PickCenterType;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormFactoryInterface;
use Chill\MainBundle\Service\Regroupement\CenterRegroupementResolver;
use Doctrine\Common\Collections\Collection;
final readonly class ExportFormHelper
{
public function __construct(
private AuthorizationHelperForCurrentUserInterface $authorizationHelper,
private ExportManager $exportManager,
private FormFactoryInterface $formFactory,
private ExportConfigNormalizer $configNormalizer,
private CenterRegroupementResolver $centerRegroupementResolver,
) {}
public function getDefaultData(string $step, DirectExportInterface|ExportInterface $export, array $options = []): array
{
return match ($step) {
'centers', 'generate_centers' => ['centers' => $this->authorizationHelper->getReachableCenters($export->requiredRole())],
'centers', 'generate_centers' => ['centers' => $this->authorizationHelper->getReachableCenters($export->requiredRole()), 'regroupments' => []],
'export', 'generate_export' => ['export' => $this->getDefaultDataStepExport($export, $options)],
'formatter', 'generate_formatter' => ['formatter' => $this->getDefaultDataStepFormatter($options)],
default => throw new \LogicException('step not allowed : '.$step),
@@ -91,80 +92,68 @@ final readonly class ExportFormHelper
}
public function savedExportDataToFormData(
SavedExport $savedExport,
ExportGeneration|SavedExport $savedExport,
string $step,
array $formOptions = [],
): array {
return match ($step) {
'centers', 'generate_centers' => $this->savedExportDataToFormDataStepCenter($savedExport),
'export', 'generate_export' => $this->savedExportDataToFormDataStepExport($savedExport, $formOptions),
'formatter', 'generate_formatter' => $this->savedExportDataToFormDataStepFormatter($savedExport, $formOptions),
'export', 'generate_export' => $this->savedExportDataToFormDataStepExport($savedExport),
'formatter', 'generate_formatter' => $this->savedExportDataToFormDataStepFormatter($savedExport),
default => throw new \LogicException('this step is not allowed: '.$step),
};
}
private function savedExportDataToFormDataStepCenter(
SavedExport $savedExport,
ExportGeneration|SavedExport $savedExport,
): array {
$builder = $this->formFactory
->createBuilder(
FormType::class,
[],
[
'csrf_protection' => false,
]
);
$builder->add('centers', PickCenterType::class, [
'export_alias' => $savedExport->getExportAlias(),
]);
$form = $builder->getForm();
$form->submit($savedExport->getOptions()['centers']);
return $form->getData();
return [
'centers' => $this->configNormalizer->denormalizeConfig($savedExport->getExportAlias(), $savedExport->getOptions(), true)['centers'],
];
}
private function savedExportDataToFormDataStepExport(
SavedExport $savedExport,
array $formOptions,
ExportGeneration|SavedExport $savedExport,
): array {
$builder = $this->formFactory
->createBuilder(
FormType::class,
[],
[
'csrf_protection' => false,
]
);
$data = $this->configNormalizer->denormalizeConfig($savedExport->getExportAlias(), $savedExport->getOptions(), true);
$builder->add('export', ExportType::class, [
'export_alias' => $savedExport->getExportAlias(), ...$formOptions,
]);
$form = $builder->getForm();
$form->submit($savedExport->getOptions()['export']);
return $form->getData();
return [
'export' => [
'export' => $data['export'],
'filters' => $data['filters'],
'pick_formatter' => ['alias' => $data['pick_formatter']],
'aggregators' => $data['aggregators'],
],
];
}
private function savedExportDataToFormDataStepFormatter(
SavedExport $savedExport,
array $formOptions,
ExportGeneration|SavedExport $savedExport,
): array {
$builder = $this->formFactory
->createBuilder(
FormType::class,
[],
[
'csrf_protection' => false,
]
);
$data = $this->configNormalizer->denormalizeConfig($savedExport->getExportAlias(), $savedExport->getOptions(), true);
$builder->add('formatter', FormatterType::class, [
'export_alias' => $savedExport->getExportAlias(), ...$formOptions,
]);
$form = $builder->getForm();
$form->submit($savedExport->getOptions()['formatter']);
return [
'formatter' => $data['formatter'],
];
}
return $form->getData();
/**
* Get the Center picked by the user for this export. The data are
* extracted from the PickCenterType data.
*
* @param array $data the data as given by the @see{Chill\MainBundle\Form\Type\Export\PickCenterType}
*
* @return list<Center>
*/
public function getPickedCenters(array $data): array
{
if (!array_key_exists('centers', $data)) {
throw new \RuntimeException('array has not the expected shape');
}
$centers = $data['centers'] instanceof Collection ? $data['centers']->toArray() : $data['centers'];
$regroupments = ($data['regroupments'] ?? []) instanceof Collection ? $data['regroupments']->toArray() : ($data['regroupments'] ?? []);
return $this->centerRegroupementResolver->resolveCenters($regroupments, $centers);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export;
use Chill\MainBundle\Entity\User;
class ExportGenerationContext
{
public function __construct(
public readonly User $byUser,
) {}
}

View File

@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\Exception\UnauthorizedGenerationException;
use Chill\MainBundle\Form\Type\Export\ExportType;
use Chill\MainBundle\Repository\CenterRepositoryInterface;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\MainBundle\Service\Regroupement\CenterRegroupementResolver;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\QueryBuilder;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\Response;
/**
* Generate a single export.
*/
final readonly class ExportGenerator
{
private bool $filterStatsByCenters;
public function __construct(
private ExportManager $exportManager,
private ExportConfigNormalizer $configNormalizer,
private LoggerInterface $logger,
private AuthorizationHelperInterface $authorizationHelper,
private CenterRegroupementResolver $centerRegroupementResolver,
private ExportConfigProcessor $exportConfigProcessor,
ParameterBagInterface $parameterBag,
private CenterRepositoryInterface $centerRepository,
) {
$this->filterStatsByCenters = $parameterBag->get('chill_main')['acl']['filter_stats_by_center'];
}
public function generate(string $exportAlias, array $configuration, ?User $byUser = null): FormattedExportGeneration
{
$data = $this->configNormalizer->denormalizeConfig($exportAlias, $configuration);
$export = $this->exportManager->getExport($exportAlias);
$centers = $this->filterCenters($byUser, $data['centers']['centers'], $data['centers']['regroupments'], $export);
$context = new ExportGenerationContext($byUser);
if ($export instanceof DirectExportInterface) {
$generatedExport = $export->generate(
$this->buildCenterReachableScopes($centers),
$data['export'],
$context,
);
if ($generatedExport instanceof Response) {
trigger_deprecation('chill-project/chill-bundles', '3.10', 'DirectExportInterface should not return a %s instance, but a %s instance', Response::class, FormattedExportGeneration::class);
return new FormattedExportGeneration($generatedExport->getContent(), $generatedExport->headers->get('Content-Type'));
}
return $generatedExport;
}
$query = $export->initiateQuery(
$this->retrieveUsedModifiers($data),
$this->buildCenterReachableScopes($centers),
$data['export'],
$context,
);
if ($query instanceof \Doctrine\ORM\NativeQuery) {
// throw an error if the export require other modifier, which is
// not allowed when the export return a `NativeQuery`
if (\count($export->supportsModifiers()) > 0) {
throw new \LogicException("The export with alias `{$exportAlias}` return ".'a `\Doctrine\ORM\NativeQuery` and supports modifiers, which is not allowed. Either the method `supportsModifiers` should return an empty array, or return a `Doctrine\ORM\QueryBuilder`');
}
} elseif ($query instanceof QueryBuilder) {
// handle filters
$this->handleFilters($query, $data[ExportType::FILTER_KEY], $context);
// handle aggregators
$this->handleAggregators($query, $data[ExportType::AGGREGATOR_KEY], $context);
$this->logger->notice('[export] will execute this qb in export', [
'dql' => $query->getDQL(),
]);
$this->logger->debug('[export] will execute this sql qb in export', [
'sql' => $query->getQuery()->getSQL(),
]);
} else {
throw new \UnexpectedValueException('The method `intiateQuery` should return a `\Doctrine\ORM\NativeQuery` or a `Doctrine\ORM\QueryBuilder` object.');
}
$result = $export->getResult($query, $data['export'], $context);
$formatter = $this->exportManager->getFormatter($data['pick_formatter']);
$filtersData = [];
$aggregatorsData = [];
if ($query instanceof QueryBuilder) {
foreach ($this->exportConfigProcessor->retrieveUsedAggregators($data[ExportType::AGGREGATOR_KEY]) as $alias => $aggregator) {
$aggregatorsData[$alias] = $data[ExportType::AGGREGATOR_KEY][$alias]['form'];
}
foreach ($this->exportConfigProcessor->retrieveUsedFilters($data[ExportType::FILTER_KEY]) as $alias => $filter) {
$filtersData[$alias] = $data[ExportType::FILTER_KEY][$alias]['form'];
}
}
/* @phpstan-ignore-next-line the method "generate" is not yet implemented on all formatters */
if (method_exists($formatter, 'generate')) {
return $formatter->generate(
$result,
$data['formatter'],
$exportAlias,
$data['export'],
$filtersData,
$aggregatorsData,
$context,
);
}
trigger_deprecation('chill-project/chill-bundles', '3.10', '%s should implements the "generate" method', FormatterInterface::class);
/* @phpstan-ignore-next-line this is a deprecated method that we must still call */
$generatedExport = $formatter->getResponse(
$result,
$data['formatter'],
$exportAlias,
$data['export'],
$filtersData,
$aggregatorsData,
$context,
);
return new FormattedExportGeneration($generatedExport->getContent(), $generatedExport->headers->get('content-type'));
}
private function filterCenters(User $byUser, array $centers, array $regroupements, ExportInterface|DirectExportInterface $export): array
{
if (!$this->filterStatsByCenters) {
return $this->centerRepository->findActive();
}
$authorizedCenters = new ArrayCollection($this->authorizationHelper->getReachableCenters($byUser, $export->requiredRole()));
if ($authorizedCenters->isEmpty()) {
throw new UnauthorizedGenerationException('No authorized centers');
}
$wantedCenters = $this->centerRegroupementResolver->resolveCenters($regroupements, $centers);
$resolvedCenters = [];
foreach ($wantedCenters as $wantedCenter) {
if ($authorizedCenters->contains($wantedCenter)) {
$resolvedCenters[] = $wantedCenter;
}
}
if ([] == $resolvedCenters) {
throw new UnauthorizedGenerationException('No common centers between wanted centers and authorized centers');
}
return $resolvedCenters;
}
/**
* parse the data to retrieve the used filters and aggregators.
*
* @return list<string>
*/
private function retrieveUsedModifiers(mixed $data): array
{
if (null === $data) {
return [];
}
$usedTypes = array_merge(
$this->retrieveUsedFiltersType($data[ExportType::FILTER_KEY]),
$this->retrieveUsedAggregatorsType($data[ExportType::AGGREGATOR_KEY]),
);
return array_values(array_unique($usedTypes));
}
/**
* Retrieve the filter used in this export.
*
* @return list<string> an array with types
*/
private function retrieveUsedFiltersType(mixed $data): array
{
if (null === $data) {
return [];
}
$usedTypes = [];
foreach ($this->exportConfigProcessor->retrieveUsedFilters($data) as $filter) {
if (!\in_array($filter->applyOn(), $usedTypes, true)) {
$usedTypes[] = $filter->applyOn();
}
}
return $usedTypes;
}
/**
* @return string[]
*/
private function retrieveUsedAggregatorsType(mixed $data): array
{
if (null === $data) {
return [];
}
$usedTypes = [];
foreach ($this->exportConfigProcessor->retrieveUsedAggregators($data) as $alias => $aggregator) {
if (!\in_array($aggregator->applyOn(), $usedTypes, true)) {
$usedTypes[] = $aggregator->applyOn();
}
}
return $usedTypes;
}
/**
* Alter the query with selected aggregators.
*/
private function handleAggregators(
QueryBuilder $qb,
array $data,
ExportGenerationContext $context,
): void {
foreach ($this->exportConfigProcessor->retrieveUsedAggregators($data) as $alias => $aggregator) {
$formData = $data[$alias];
$aggregator->alterQuery($qb, $formData['form'], $context);
}
}
/**
* alter the query with selected filters.
*/
private function handleFilters(
QueryBuilder $qb,
mixed $data,
ExportGenerationContext $context,
): void {
foreach ($this->exportConfigProcessor->retrieveUsedFilters($data) as $alias => $filter) {
$formData = $data[$alias];
$filter->alterQuery($qb, $formData['form'], $context);
}
}
/**
* build the array required for defining centers and circles in the initiate
* queries of ExportElementsInterfaces.
*
* @param list<Center> $centers
*/
private function buildCenterReachableScopes(array $centers)
{
return array_map(static fn (Center $center) => ['center' => $center, 'circles' => []], $centers);
}
}

View File

@@ -14,6 +14,7 @@ namespace Chill\MainBundle\Export;
use Doctrine\ORM\NativeQuery;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Contracts\Translation\TranslatableInterface;
/**
* Interface for Export.
@@ -27,6 +28,7 @@ use Symfony\Component\Form\FormBuilderInterface;
* @example Chill\PersonBundle\Export\CountPerson an example of implementation
*
* @template Q of QueryBuilder|NativeQuery
* @template D of array
*/
interface ExportInterface extends ExportElementInterface
{
@@ -94,12 +96,12 @@ interface ExportInterface extends ExportElementInterface
* which do not need to be translated, or value already translated in
* database. But the header must be, in every case, translated.
*
* @param string $key The column key, as added in the query
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
* @param string $key The column key, as added in the query
* @param list<mixed> $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
*
* @return (callable(string|int|float|'_header'|null $value): string|int|\DateTimeInterface) where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
* @return (callable(string|int|float|'_header'|null $value): string|int|\DateTimeInterface|TranslatableInterface) where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
*/
public function getLabels($key, array $values, mixed $data);
public function getLabels(string $key, array $values, mixed $data);
/**
* give the list of keys the current export added to the queryBuilder in
@@ -108,29 +110,27 @@ interface ExportInterface extends ExportElementInterface
* Example: if your query builder will contains `SELECT count(id) AS count_id ...`,
* this function will return `array('count_id')`.
*
* @param mixed[] $data the data from the export's form (added by self::buildForm)
* @param D $data the data from the export's form (added by self::buildForm)
*/
public function getQueryKeys($data);
public function getQueryKeys(array $data): array;
/**
* Return the results of the query builder.
*
* @param Q $query
* @param mixed[] $data the data from the export's fomr (added by self::buildForm)
* @param Q $query
* @param D $data the data from the export's fomr (added by self::buildForm)
*
* @return mixed[] an array of results
*/
public function getResult($query, $data);
public function getResult(QueryBuilder|NativeQuery $query, array $data, ExportGenerationContext $context): array;
/**
* Return the Export's type. This will inform _on what_ export will apply.
* Most of the type, it will be a string which references an entity.
*
* Example of types : Chill\PersonBundle\Export\Declarations::PERSON_TYPE
*
* @return string
*/
public function getType();
public function getType(): string;
/**
* The initial query, which will be modified by ModifiersInterface
@@ -147,7 +147,21 @@ interface ExportInterface extends ExportElementInterface
*
* @return Q the query to execute
*/
public function initiateQuery(array $requiredModifiers, array $acl, array $data = []);
public function initiateQuery(array $requiredModifiers, array $acl, array $data, ExportGenerationContext $context): QueryBuilder|NativeQuery;
/**
* @param D $formData
*/
public function normalizeFormData(array $formData): array;
/**
* @param array $formData the normalized data
*
* @return D
*/
public function denormalizeFormData(array $formData, int $fromVersion): array;
public function getNormalizationVersion(): int;
/**
* Return the required Role to execute the Export.

View File

@@ -11,13 +11,9 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\Type\Export\ExportType;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Doctrine\ORM\QueryBuilder;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
@@ -63,14 +59,13 @@ class ExportManager
iterable $exports,
iterable $aggregators,
iterable $filters,
// iterable $formatters,
iterable $formatters,
// iterable $exportElementProvider
) {
$this->exports = iterator_to_array($exports);
$this->aggregators = iterator_to_array($aggregators);
$this->filters = iterator_to_array($filters);
// NOTE: PHP crashes on the next line (exit error code 11). This is desactivated until further investigation
// $this->formatters = iterator_to_array($formatters);
$this->formatters = iterator_to_array($formatters);
// foreach ($exportElementProvider as $prefix => $provider) {
// $this->addExportElementsProvider($provider, $prefix);
@@ -102,7 +97,7 @@ class ExportManager
\in_array($filter->applyOn(), $export->supportsModifiers(), true)
&& $this->isGrantedForElement($filter, $export, $centers)
) {
$filters[$alias] = $filter;
$filters[$alias] = $this->getFilter($alias);
}
}
@@ -136,125 +131,32 @@ class ExportManager
return $aggregators;
}
public function addExportElementsProvider(ExportElementsProviderInterface $provider, string $prefix): void
{
foreach ($provider->getExportElements() as $suffix => $element) {
$alias = $prefix.'_'.$suffix;
if ($element instanceof ExportInterface) {
$this->exports[$alias] = $element;
} elseif ($element instanceof FilterInterface) {
$this->filters[$alias] = $element;
} elseif ($element instanceof AggregatorInterface) {
$this->aggregators[$alias] = $element;
} elseif ($element instanceof FormatterInterface) {
$this->addFormatter($element, $alias);
} else {
throw new \LogicException('This element '.$element::class.' is not an instance of export element');
}
}
}
/**
* add a formatter.
*
* @internal used by DI
*/
public function addFormatter(FormatterInterface $formatter, string $alias): void
public function addFormatter(FormatterInterface $formatter, string $alias)
{
$this->formatters[$alias] = $formatter;
}
/**
* Generate a response which contains the requested data.
*/
public function generate(string $exportAlias, array $pickedCentersData, array $data, array $formatterData): Response
{
$export = $this->getExport($exportAlias);
$centers = $this->getPickedCenters($pickedCentersData);
if ($export instanceof DirectExportInterface) {
return $export->generate(
$this->buildCenterReachableScopes($centers, $export),
$data[ExportType::EXPORT_KEY]
);
}
$query = $export->initiateQuery(
$this->retrieveUsedModifiers($data),
$this->buildCenterReachableScopes($centers, $export),
$data[ExportType::EXPORT_KEY]
);
if ($query instanceof \Doctrine\ORM\NativeQuery) {
// throw an error if the export require other modifier, which is
// not allowed when the export return a `NativeQuery`
if (\count($export->supportsModifiers()) > 0) {
throw new \LogicException("The export with alias `{$exportAlias}` return ".'a `\Doctrine\ORM\NativeQuery` and supports modifiers, which is not allowed. Either the method `supportsModifiers` should return an empty array, or return a `Doctrine\ORM\QueryBuilder`');
}
} elseif ($query instanceof QueryBuilder) {
// handle filters
$this->handleFilters($export, $query, $data[ExportType::FILTER_KEY], $centers);
// handle aggregators
$this->handleAggregators($export, $query, $data[ExportType::AGGREGATOR_KEY], $centers);
$this->logger->notice('[export] will execute this qb in export', [
'dql' => $query->getDQL(),
]);
} else {
throw new \UnexpectedValueException('The method `intiateQuery` should return a `\Doctrine\ORM\NativeQuery` or a `Doctrine\ORM\QueryBuilder` object.');
}
$result = $export->getResult($query, $data[ExportType::EXPORT_KEY]);
if (!is_iterable($result)) {
throw new \UnexpectedValueException(sprintf('The result of the export should be an iterable, %s given', \gettype($result)));
}
/** @var FormatterInterface $formatter */
$formatter = $this->getFormatter($this->getFormatterAlias($data));
$filtersData = [];
$aggregatorsData = [];
if ($query instanceof QueryBuilder) {
$aggregators = $this->retrieveUsedAggregators($data[ExportType::AGGREGATOR_KEY]);
foreach ($aggregators as $alias => $aggregator) {
$aggregatorsData[$alias] = $data[ExportType::AGGREGATOR_KEY][$alias]['form'];
}
}
$filters = $this->retrieveUsedFilters($data[ExportType::FILTER_KEY]);
foreach ($filters as $alias => $filter) {
$filtersData[$alias] = $data[ExportType::FILTER_KEY][$alias]['form'];
}
return $formatter->getResponse(
$result,
$formatterData,
$exportAlias,
$data[ExportType::EXPORT_KEY],
$filtersData,
$aggregatorsData
);
}
/**
* @param string $alias
*
* @return AggregatorInterface
*
* @throws \RuntimeException if the aggregator is not known
*/
public function getAggregator($alias)
public function getAggregator($alias): AggregatorInterface
{
if (!\array_key_exists($alias, $this->aggregators)) {
if (null === $aggregator = $this->aggregators[$alias] ?? null) {
throw new \RuntimeException("The aggregator with alias {$alias} is not known.");
}
return $this->aggregators[$alias];
if ($aggregator instanceof ExportManagerAwareInterface) {
$aggregator->setExportManager($this);
}
return $aggregator;
}
/**
@@ -313,10 +215,10 @@ class ExportManager
foreach ($this->exports as $alias => $export) {
if ($whereUserIsGranted) {
if ($this->isGrantedForElement($export, null, null)) {
yield $alias => $export;
yield $alias => $this->getExport($alias);
}
} else {
yield $alias => $export;
yield $alias => $this->getExport($alias);
}
}
}
@@ -332,9 +234,9 @@ class ExportManager
foreach ($this->getExports($whereUserIsGranted) as $alias => $export) {
if ($export instanceof GroupedExportInterface) {
$groups[$export->getGroup()][$alias] = $export;
$groups[$export->getGroup()][$alias] = $this->getExport($alias);
} else {
$groups['_'][$alias] = $export;
$groups['_'][$alias] = $this->getExport($alias);
}
}
@@ -346,11 +248,25 @@ class ExportManager
*/
public function getFilter(string $alias): FilterInterface
{
if (!\array_key_exists($alias, $this->filters)) {
if (null === $filter = $this->filters[$alias] ?? null) {
throw new \RuntimeException("The filter with alias {$alias} is not known.");
}
return $this->filters[$alias];
if ($filter instanceof ExportManagerAwareInterface) {
$filter->setExportManager($this);
}
return $filter;
}
public function hasFilter(string $alias): bool
{
return array_key_exists($alias, $this->filters);
}
public function hasAggregator(string $alias): bool
{
return array_key_exists($alias, $this->aggregators);
}
public function getAllFilters(): array
@@ -358,7 +274,7 @@ class ExportManager
$filters = [];
foreach ($this->filters as $alias => $filter) {
$filters[$alias] = $filter;
$filters[$alias] = $this->getFilter($alias);
}
return $filters;
@@ -380,11 +296,15 @@ class ExportManager
public function getFormatter(string $alias): FormatterInterface
{
if (!\array_key_exists($alias, $this->formatters)) {
if (null === $formatter = $this->formatters[$alias] ?? null) {
throw new \RuntimeException("The formatter with alias {$alias} is not known.");
}
return $this->formatters[$alias];
if ($formatter instanceof ExportManagerAwareInterface) {
$formatter->setExportManager($this);
}
return $formatter;
}
/**
@@ -412,26 +332,13 @@ class ExportManager
{
foreach ($this->formatters as $alias => $formatter) {
if (\in_array($formatter->getType(), $types, true)) {
yield $alias => $formatter;
yield $alias => $this->getFormatter($alias);
}
}
}
/**
* Get the Center picked by the user for this export. The data are
* extracted from the PickCenterType data.
*
* @param array $data the data from a PickCenterType
*
* @return \Chill\MainBundle\Entity\Center[] the picked center
*/
public function getPickedCenters(array $data): array
{
return $data;
}
/**
* get the aggregators typse used in the form export data.
* get the aggregators types used in the form export data.
*
* @param array $data the data from the export form
*
@@ -439,9 +346,15 @@ class ExportManager
*/
public function getUsedAggregatorsAliases(array $data): array
{
$aggregators = $this->retrieveUsedAggregators($data[ExportType::AGGREGATOR_KEY]);
$keys = [];
return array_keys(iterator_to_array($aggregators));
foreach ($data as $alias => $aggregatorData) {
if (true === $aggregatorData['enabled']) {
$keys[] = $alias;
}
}
return array_values(array_unique($keys));
}
/**
@@ -490,190 +403,4 @@ class ExportManager
return true;
}
/**
* build the array required for defining centers and circles in the initiate
* queries of ExportElementsInterfaces.
*
* @param \Chill\MainBundle\Entity\Center[] $centers
*/
private function buildCenterReachableScopes(array $centers, ExportElementInterface $element)
{
$r = [];
$user = $this->tokenStorage->getToken()->getUser();
if (!$user instanceof User) {
return [];
}
foreach ($centers as $center) {
$r[] = [
'center' => $center,
'circles' => $this->authorizationHelper->getReachableScopes(
$user,
$element->requiredRole(),
$center
),
];
}
return $r;
}
/**
* Alter the query with selected aggregators.
*
* Check for acl. If an user is not authorized to see an aggregator, throw an
* UnauthorizedException.
*
* @throw UnauthorizedHttpException if the user is not authorized
*/
private function handleAggregators(
ExportInterface $export,
QueryBuilder $qb,
array $data,
array $center,
): void {
$aggregators = $this->retrieveUsedAggregators($data);
foreach ($aggregators as $alias => $aggregator) {
if (false === $this->isGrantedForElement($aggregator, $export, $center)) {
throw new UnauthorizedHttpException('You are not authorized to use the aggregator'.$aggregator->getTitle());
}
$formData = $data[$alias];
$aggregator->alterQuery($qb, $formData['form']);
}
}
/**
* alter the query with selected filters.
*
* This function check the acl.
*
* @param \Chill\MainBundle\Entity\Center[] $centers the picked centers
*
* @throw UnauthorizedHttpException if the user is not authorized
*/
private function handleFilters(
ExportInterface $export,
QueryBuilder $qb,
mixed $data,
array $centers,
): void {
$filters = $this->retrieveUsedFilters($data);
foreach ($filters as $alias => $filter) {
if (false === $this->isGrantedForElement($filter, $export, $centers)) {
throw new UnauthorizedHttpException('You are not authorized to use the filter '.$filter->getTitle());
}
$formData = $data[$alias];
$this->logger->debug('alter query by filter '.$alias, [
'class' => self::class, 'function' => __FUNCTION__,
]);
$filter->alterQuery($qb, $formData['form']);
}
}
/**
* @return iterable<string, AggregatorInterface>
*/
private function retrieveUsedAggregators(mixed $data): iterable
{
if (null === $data) {
return [];
}
foreach ($data as $alias => $aggregatorData) {
if (true === $aggregatorData['enabled']) {
yield $alias => $this->getAggregator($alias);
}
}
}
/**
* @return string[]
*/
private function retrieveUsedAggregatorsType(mixed $data)
{
if (null === $data) {
return [];
}
$usedTypes = [];
foreach ($this->retrieveUsedAggregators($data) as $alias => $aggregator) {
if (!\in_array($aggregator->applyOn(), $usedTypes, true)) {
$usedTypes[] = $aggregator->applyOn();
}
}
return $usedTypes;
}
private function retrieveUsedFilters(mixed $data): iterable
{
if (null === $data) {
return [];
}
foreach ($data as $alias => $filterData) {
if (true === $filterData['enabled']) {
yield $alias => $this->getFilter($alias);
}
}
}
/**
* Retrieve the filter used in this export.
*
* @return array an array with types
*/
private function retrieveUsedFiltersType(mixed $data): iterable
{
if (null === $data) {
return [];
}
$usedTypes = [];
foreach ($data as $alias => $filterData) {
if (true === $filterData['enabled']) {
$filter = $this->getFilter($alias);
if (!\in_array($filter->applyOn(), $usedTypes, true)) {
$usedTypes[] = $filter->applyOn();
}
}
}
return $usedTypes;
}
/**
* parse the data to retrieve the used filters and aggregators.
*
* @return string[]
*/
private function retrieveUsedModifiers(mixed $data): array
{
if (null === $data) {
return [];
}
$usedTypes = array_merge(
$this->retrieveUsedFiltersType($data[ExportType::FILTER_KEY]),
$this->retrieveUsedAggregatorsType($data[ExportType::AGGREGATOR_KEY])
);
$this->logger->debug(
'Required types are '.implode(', ', $usedTypes),
['class' => self::class, 'function' => __FUNCTION__]
);
return array_unique($usedTypes);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export;
/**
* Interface which is aware of the export manager.
*/
interface ExportManagerAwareInterface
{
public function setExportManager(ExportManager $exportManager): void;
}

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Contracts\Translation\TranslatableInterface;
/**
* Interface for filters.
@@ -20,6 +21,8 @@ use Symfony\Component\Form\FormBuilderInterface;
* it will add a `WHERE` clause on this query.
*
* Filters should not add column in `SELECT` clause.
*
* @template D of array
*/
interface FilterInterface extends ModifierInterface
{
@@ -28,16 +31,30 @@ interface FilterInterface extends ModifierInterface
/**
* Add a form to collect data from the user.
*/
public function buildForm(FormBuilderInterface $builder);
public function buildForm(FormBuilderInterface $builder): void;
/**
* Get the default data, that can be use as "data" for the form.
*
* In case of adding new parameters to a filter, you can implement a @see{DataTransformerFilterInterface} to
* transforme the filters's data saved in an export to the desired state.
*
* @return D
*/
public function getFormDefaultData(): array;
/**
* @param D $formData
*/
public function normalizeFormData(array $formData): array;
/**
* @return D
*/
public function denormalizeFormData(array $formData, int $fromVersion): array;
public function getNormalizationVersion(): int;
/**
* Describe the filtering action.
*
@@ -52,7 +69,7 @@ interface FilterInterface extends ModifierInterface
* supported, later some 'html' will be added. The filter should always
* implement the 'string' format and fallback to it if other format are used.
*
* If no i18n is necessery, or if the filter translate the string by himself,
* If no i18n is necessary, or if the filter translate the string by himself,
* this function should return a string. If the filter does not do any translation,
* the return parameter should be an array, where
*
@@ -63,10 +80,9 @@ interface FilterInterface extends ModifierInterface
*
* Example: `array('my string with %parameter%', ['%parameter%' => 'good news'], 'mydomain', 'mylocale')`
*
* @param array $data
* @param string $format the format
* @param D $data
*
* @return array|string a string with the data or, if translatable, an array where first element is string, second elements is an array of arguments
* @return array|string|TranslatableInterface a string with the data or, if translatable, an array where first element is string, second elements is an array of arguments
*/
public function describeAction($data, $format = 'string');
public function describeAction(array $data, ExportGenerationContext $context): array|string|TranslatableInterface;
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export;
final readonly class FormattedExportGeneration
{
public function __construct(
public string $content,
public string $contentType,
) {}
}

View File

@@ -1,224 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export\Formatter;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Export\FormatterInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatorInterface;
use function count;
// command to get the report with curl : curl --user "center a_social:password" "http://localhost:8000/fr/exports/generate/count_person?export[filters][person_gender_filter][enabled]=&export[filters][person_nationality_filter][enabled]=&export[filters][person_nationality_filter][form][nationalities]=&export[aggregators][person_nationality_aggregator][order]=1&export[aggregators][person_nationality_aggregator][form][group_by_level]=country&export[submit]=&export[_token]=RHpjHl389GrK-bd6iY5NsEqrD5UKOTHH40QKE9J1edU" --globoff
/**
* Create a CSV List for the export.
*/
class CSVListFormatter implements FormatterInterface
{
protected $exportAlias;
protected $exportData;
/**
* @var ExportManager
*/
protected $exportManager;
protected $formatterData;
/**
* This variable cache the labels internally.
*
* @var string[]
*/
protected $labelsCache;
protected $result;
/**
* @var TranslatorInterface
*/
protected $translator;
public function __construct(TranslatorInterface $translatorInterface, ExportManager $exportManager)
{
$this->translator = $translatorInterface;
$this->exportManager = $exportManager;
}
/**
* build a form, which will be used to collect data required for the execution
* of this formatter.
*
* @uses appendAggregatorForm
*
* @param type $exportAlias
*/
public function buildForm(
FormBuilderInterface $builder,
$exportAlias,
array $aggregatorAliases,
): void {
$builder->add('numerotation', ChoiceType::class, [
'choices' => [
'yes' => true,
'no' => false,
],
'expanded' => true,
'multiple' => false,
'label' => 'Add a number on first column',
]);
}
public function getFormDefaultData(array $aggregatorAliases): array
{
return ['numerotation' => true];
}
public function getName()
{
return 'CSV vertical list';
}
/**
* Generate a response from the data collected on differents ExportElementInterface.
*
* @param mixed[] $result The result, as given by the ExportInterface
* @param mixed[] $formatterData collected from the current form
* @param string $exportAlias the id of the current export
* @param array $filtersData an array containing the filters data. The key are the filters id, and the value are the data
* @param array $aggregatorsData an array containing the aggregators data. The key are the filters id, and the value are the data
*
* @return Response The response to be shown
*/
public function getResponse(
$result,
$formatterData,
$exportAlias,
array $exportData,
array $filtersData,
array $aggregatorsData,
): \Symfony\Component\HttpFoundation\Response {
$this->result = $result;
$this->exportAlias = $exportAlias;
$this->exportData = $exportData;
$this->formatterData = $formatterData;
$output = fopen('php://output', 'wb');
$this->prepareHeaders($output);
$i = 1;
foreach ($result as $row) {
$line = [];
if (true === $this->formatterData['numerotation']) {
$line[] = $i;
}
foreach ($row as $key => $value) {
$line[] = $this->getLabel($key, $value);
}
fputcsv($output, $line);
++$i;
}
$csvContent = stream_get_contents($output);
fclose($output);
$response = new Response();
$response->setStatusCode(200);
$response->headers->set('Content-Type', 'text/csv; charset=utf-8');
// $response->headers->set('Content-Disposition','attachment; filename="export.csv"');
$response->setContent($csvContent);
return $response;
}
public function getType()
{
return FormatterInterface::TYPE_LIST;
}
/**
* Give the label corresponding to the given key and value.
*
* @param string $key
* @param string $value
*
* @return string
*
* @throws \LogicException if the label is not found
*/
protected function getLabel($key, $value)
{
if (null === $this->labelsCache) {
$this->prepareCacheLabels();
}
if (!\array_key_exists($key, $this->labelsCache)) {
throw new \OutOfBoundsException(sprintf('The key "%s" is not present in the list of keys handled by this query. Check your `getKeys` and `getLabels` methods. Available keys are %s.', $key, \implode(', ', \array_keys($this->labelsCache))));
}
return $this->labelsCache[$key]($value);
}
/**
* Prepare the label cache which will be used by getLabel. This function
* should be called only once in the generation lifecycle.
*/
protected function prepareCacheLabels()
{
$export = $this->exportManager->getExport($this->exportAlias);
$keys = $export->getQueryKeys($this->exportData);
foreach ($keys as $key) {
// get an array with all values for this key if possible
$values = \array_map(static fn ($v) => $v[$key], $this->result);
// store the label in the labelsCache property
$this->labelsCache[$key] = $export->getLabels($key, $values, $this->exportData);
}
}
/**
* add the headers to the csv file.
*
* @param resource $output
*/
protected function prepareHeaders($output)
{
$keys = $this->exportManager->getExport($this->exportAlias)->getQueryKeys($this->exportData);
// we want to keep the order of the first row. So we will iterate on the first row of the results
$first_row = \count($this->result) > 0 ? $this->result[0] : [];
$header_line = [];
if (true === $this->formatterData['numerotation']) {
$header_line[] = $this->translator->trans('Number');
}
foreach ($first_row as $key => $value) {
$header_line[] = $this->translator->trans(
$this->getLabel($key, '_header')
);
}
if (\count($header_line) > 0) {
fputcsv($output, $header_line);
}
}
}

View File

@@ -1,217 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export\Formatter;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Export\FormatterInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Create a CSV List for the export where the header are printed on the
* first column, and the result goes from left to right.
*/
class CSVPivotedListFormatter implements FormatterInterface
{
protected $exportAlias;
protected $exportData;
/**
* @var ExportManager
*/
protected $exportManager;
protected $formatterData;
/**
* This variable cache the labels internally.
*
* @var string[]
*/
protected $labelsCache;
protected $result;
/**
* @var TranslatorInterface
*/
protected $translator;
public function __construct(TranslatorInterface $translatorInterface, ExportManager $exportManager)
{
$this->translator = $translatorInterface;
$this->exportManager = $exportManager;
}
/**
* build a form, which will be used to collect data required for the execution
* of this formatter.
*
* @uses appendAggregatorForm
*
* @param type $exportAlias
*/
public function buildForm(
FormBuilderInterface $builder,
$exportAlias,
array $aggregatorAliases,
): void {
$builder->add('numerotation', ChoiceType::class, [
'choices' => [
'yes' => true,
'no' => false,
],
'expanded' => true,
'multiple' => false,
'label' => 'Add a number on first column',
'data' => true,
]);
}
public function getFormDefaultData(array $aggregatorAliases): array
{
return ['numerotation' => true];
}
public function getName()
{
return 'CSV horizontal list';
}
/**
* Generate a response from the data collected on differents ExportElementInterface.
*
* @param mixed[] $result The result, as given by the ExportInterface
* @param mixed[] $formatterData collected from the current form
* @param string $exportAlias the id of the current export
* @param array $filtersData an array containing the filters data. The key are the filters id, and the value are the data
* @param array $aggregatorsData an array containing the aggregators data. The key are the filters id, and the value are the data
*
* @return Response The response to be shown
*/
public function getResponse(
$result,
$formatterData,
$exportAlias,
array $exportData,
array $filtersData,
array $aggregatorsData,
): \Symfony\Component\HttpFoundation\Response {
$this->result = $result;
$this->exportAlias = $exportAlias;
$this->exportData = $exportData;
$this->formatterData = $formatterData;
$output = fopen('php://output', 'wb');
$i = 1;
$lines = [];
$this->prepareHeaders($lines);
foreach ($result as $row) {
$j = 0;
if (true === $this->formatterData['numerotation']) {
$lines[$j][] = $i;
++$j;
}
foreach ($row as $key => $value) {
$lines[$j][] = $this->getLabel($key, $value);
++$j;
}
++$i;
}
// adding the lines to the csv output
foreach ($lines as $line) {
fputcsv($output, $line);
}
$csvContent = stream_get_contents($output);
fclose($output);
$response = new Response();
$response->setStatusCode(200);
$response->headers->set('Content-Type', 'text/csv; charset=utf-8');
$response->headers->set('Content-Disposition', 'attachment; filename="export.csv"');
$response->setContent($csvContent);
return $response;
}
public function getType()
{
return FormatterInterface::TYPE_LIST;
}
/**
* Give the label corresponding to the given key and value.
*
* @param string $key
* @param string $value
*
* @return string
*
* @throws \LogicException if the label is not found
*/
protected function getLabel($key, $value)
{
if (null === $this->labelsCache) {
$this->prepareCacheLabels();
}
return $this->labelsCache[$key]($value);
}
/**
* Prepare the label cache which will be used by getLabel. This function
* should be called only once in the generation lifecycle.
*/
protected function prepareCacheLabels()
{
$export = $this->exportManager->getExport($this->exportAlias);
$keys = $export->getQueryKeys($this->exportData);
foreach ($keys as $key) {
// get an array with all values for this key if possible
$values = \array_map(static fn ($v) => $v[$key], $this->result);
// store the label in the labelsCache property
$this->labelsCache[$key] = $export->getLabels($key, $values, $this->exportData);
}
}
/**
* add the headers to lines array.
*
* @param array $lines the lines where the header will be added
*/
protected function prepareHeaders(array &$lines)
{
$keys = $this->exportManager->getExport($this->exportAlias)->getQueryKeys($this->exportData);
// we want to keep the order of the first row. So we will iterate on the first row of the results
$first_row = \count($this->result) > 0 ? $this->result[0] : [];
$header_line = [];
if (true === $this->formatterData['numerotation']) {
$lines[] = [$this->translator->trans('Number')];
}
foreach ($first_row as $key => $value) {
$lines[] = [$this->getLabel($key, '_header')];
}
}
}

View File

@@ -11,120 +11,26 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export\Formatter;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Export\ExportGenerationContext;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\ExportManagerAwareInterface;
use Chill\MainBundle\Export\FormattedExportGeneration;
use Chill\MainBundle\Export\FormatterInterface;
use Chill\MainBundle\Export\Helper\ExportManagerAwareTrait;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class SpreadSheetFormatter implements FormatterInterface
final class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInterface
{
/**
* an array where keys are the aggregators aliases and
* values are the data.
*
* replaced when `getResponse` is called.
*
* @var type
*/
protected $aggregatorsData;
use ExportManagerAwareTrait;
/**
* The export.
*
* replaced when `getResponse` is called.
*
* @var \Chill\MainBundle\Export\ExportInterface
*/
protected $export;
/**
* replaced when `getResponse` is called.
*
* @var type
*/
// protected $aggregators;
/**
* array containing value of export form.
*
* replaced when `getResponse` is called.
*
* @var array
*/
protected $exportData;
/**
* @var ExportManager
*/
protected $exportManager;
/**
* replaced when `getResponse` is called.
*
* @var array
*/
protected $filtersData;
/**
* replaced when `getResponse` is called.
*
* @var type
*/
protected $formatterData;
/**
* The result, as returned by the export.
*
* replaced when `getResponse` is called.
*
* @var type
*/
protected $result;
/**
* replaced when `getResponse` is called.
*
* @var array
*/
// protected $labels;
/**
* temporary file to store spreadsheet.
*
* @var string
*/
protected $tempfile;
/**
* @var TranslatorInterface
*/
protected $translator;
/**
* cache for displayable result.
*
* This cache is reset when `getResponse` is called.
*
* The array's keys are the keys in the raw result, and
* values are the callable which will transform the raw result to
* displayable result.
*/
private ?array $cacheDisplayableResult = null;
/**
* Whethe `cacheDisplayableResult` is initialized or not.
*/
private bool $cacheDisplayableResultIsInitialized = false;
public function __construct(TranslatorInterface $translatorInterface, ExportManager $exportManager)
{
$this->translator = $translatorInterface;
$this->exportManager = $exportManager;
}
public function __construct(private readonly TranslatorInterface $translator) {}
public function buildForm(
FormBuilderInterface $builder,
@@ -142,7 +48,7 @@ class SpreadSheetFormatter implements FormatterInterface
]);
// ordering aggregators
$aggregators = $this->exportManager->getAggregators($aggregatorAliases);
$aggregators = $this->getExportManager()->getAggregators($aggregatorAliases);
$nb = \count($aggregatorAliases);
foreach ($aggregators as $alias => $aggregator) {
@@ -155,11 +61,26 @@ class SpreadSheetFormatter implements FormatterInterface
}
}
public function getNormalizationVersion(): int
{
return 1;
}
public function normalizeFormData(array $formData): array
{
return $formData;
}
public function denormalizeFormData(array $formData, int $fromVersion): array
{
return $formData;
}
public function getFormDefaultData(array $aggregatorAliases): array
{
$data = ['format' => 'xlsx'];
$aggregators = iterator_to_array($this->exportManager->getAggregators($aggregatorAliases));
$aggregators = iterator_to_array($this->getExportManager()->getAggregators($aggregatorAliases));
foreach (array_keys($aggregators) as $index => $alias) {
$data[$alias] = ['order' => $index + 1];
}
@@ -172,6 +93,51 @@ class SpreadSheetFormatter implements FormatterInterface
return 'SpreadSheet (xlsx, ods)';
}
public function generate(
$result,
$formatterData,
string $exportAlias,
array $exportData,
array $filtersData,
array $aggregatorsData,
ExportGenerationContext $context,
) {
// Initialize local variables instead of class properties
/** @var ExportInterface $export */
$export = $this->getExportManager()->getExport($exportAlias);
// Initialize cache variables
$cacheDisplayableResult = $this->initializeDisplayable($result, $export, $exportData, $aggregatorsData);
$tempfile = \tempnam(\sys_get_temp_dir(), '');
if (false === $tempfile) {
throw new \RuntimeException('Unable to create temporary file');
}
$this->generateContent(
$context,
$tempfile,
$result,
$formatterData,
$export,
$exportData,
$filtersData,
$aggregatorsData,
$cacheDisplayableResult,
);
$result = new FormattedExportGeneration(
file_get_contents($tempfile),
$this->getContentType($formatterData['format']),
);
// remove the temp file from disk
\unlink($tempfile);
return $result;
}
public function getResponse(
$result,
$formatterData,
@@ -179,44 +145,22 @@ class SpreadSheetFormatter implements FormatterInterface
array $exportData,
array $filtersData,
array $aggregatorsData,
ExportGenerationContext $context,
): Response {
// store all data when the process is initiated
$this->result = $result;
$this->formatterData = $formatterData;
$this->export = $this->exportManager->getExport($exportAlias);
$this->exportData = $exportData;
$this->filtersData = $filtersData;
$this->aggregatorsData = $aggregatorsData;
$formattedResult = $this->generate($result, $formatterData, $exportAlias, $exportData, $filtersData, $aggregatorsData, $context);
// reset cache
$this->cacheDisplayableResult = [];
$this->cacheDisplayableResultIsInitialized = false;
$response = new Response();
$response->headers->set(
'Content-Type',
$this->getContentType($this->formatterData['format'])
);
$this->tempfile = \tempnam(\sys_get_temp_dir(), '');
$this->generateContent();
$f = \fopen($this->tempfile, 'rb');
$response->setContent(\stream_get_contents($f));
fclose($f);
// remove the temp file from disk
\unlink($this->tempfile);
$response = new BinaryFileResponse($formattedResult->content);
$response->headers->set('Content-Type', $formattedResult->contentType);
return $response;
}
public function getType()
public function getType(): string
{
return 'tabular';
}
protected function addContentTable(
private function addContentTable(
Worksheet $worksheet,
$sortedResults,
$line,
@@ -238,20 +182,21 @@ class SpreadSheetFormatter implements FormatterInterface
*
* @return int the line number after the last description
*/
protected function addFiltersDescription(Worksheet &$worksheet)
private function addFiltersDescription(Worksheet &$worksheet, ExportGenerationContext $context, array $filtersData)
{
$line = 3;
foreach ($this->filtersData as $alias => $data) {
$filter = $this->exportManager->getFilter($alias);
$description = $filter->describeAction($data, 'string');
foreach ($filtersData as $alias => $data) {
$filter = $this->getExportManager()->getFilter($alias);
$description = $filter->describeAction($data, $context);
if (\is_array($description)) {
$description = $this->translator
->trans(
$description[0],
$description[1] ?? []
$description[1] ?? [],
);
} elseif ($description instanceof TranslatableInterface) {
$description = $description->trans($this->translator, $this->translator->getLocale());
}
$worksheet->setCellValue('A'.$line, $description);
@@ -266,23 +211,23 @@ class SpreadSheetFormatter implements FormatterInterface
*
* return the line number where the next content (i.e. result) should
* be appended.
*
* @param int $line
*
* @return int
*/
protected function addHeaders(
private function addHeaders(
Worksheet &$worksheet,
array $globalKeys,
$line,
) {
int $line,
array $cacheDisplayableResult = [],
): int {
// get the displayable form of headers
$displayables = [];
foreach ($globalKeys as $key) {
$displayables[] = $this->translator->trans(
$this->getDisplayableResult($key, '_header')
);
$displayable = $this->getDisplayableResult($key, '_header', $cacheDisplayableResult);
if ($displayable instanceof TranslatableInterface) {
$displayables[] = $displayable->trans($this->translator, $this->translator->getLocale());
} else {
$displayables[] = $this->translator->trans($this->getDisplayableResult($key, '_header', $cacheDisplayableResult));
}
}
// add headers on worksheet
@@ -299,9 +244,9 @@ class SpreadSheetFormatter implements FormatterInterface
* Add the title to the worksheet and merge the cell containing
* the title.
*/
protected function addTitleToWorkSheet(Worksheet &$worksheet)
private function addTitleToWorkSheet(Worksheet &$worksheet, $export)
{
$worksheet->setCellValue('A1', $this->getTitle());
$worksheet->setCellValue('A1', $this->getTitle($export));
$worksheet->mergeCells('A1:G1');
}
@@ -310,14 +255,14 @@ class SpreadSheetFormatter implements FormatterInterface
*
* @return array where 1st member is spreadsheet, 2nd is worksheet
*/
protected function createSpreadsheet()
private function createSpreadsheet($export)
{
$spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$worksheet = $spreadsheet->getActiveSheet();
// setting the worksheet title and code name
$worksheet
->setTitle($this->getTitle())
->setTitle($this->getTitle($export))
->setCodeName('result');
return [$spreadsheet, $worksheet];
@@ -326,29 +271,38 @@ class SpreadSheetFormatter implements FormatterInterface
/**
* Generate the content and write it to php://temp.
*/
protected function generateContent()
{
[$spreadsheet, $worksheet] = $this->createSpreadsheet();
private function generateContent(
ExportGenerationContext $context,
string $tempfile,
$result,
$formatterData,
$export,
array $exportData,
array $filtersData,
array $aggregatorsData,
array $cacheDisplayableResult,
) {
[$spreadsheet, $worksheet] = $this->createSpreadsheet($export);
$this->addTitleToWorkSheet($worksheet);
$line = $this->addFiltersDescription($worksheet);
$this->addTitleToWorkSheet($worksheet, $export);
$line = $this->addFiltersDescription($worksheet, $context, $filtersData);
// at this point, we are going to sort retsults for an easier manipulation
// at this point, we are going to sort results for an easier manipulation
[$sortedResult, $exportKeys, $aggregatorKeys, $globalKeys] =
$this->sortResult();
$this->sortResult($result, $export, $exportData, $aggregatorsData, $formatterData, $cacheDisplayableResult);
$line = $this->addHeaders($worksheet, $globalKeys, $line);
$line = $this->addHeaders($worksheet, $globalKeys, $line, $cacheDisplayableResult);
$line = $this->addContentTable($worksheet, $sortedResult, $line);
$this->addContentTable($worksheet, $sortedResult, $line);
$writer = match ($this->formatterData['format']) {
$writer = match ($formatterData['format']) {
'ods' => \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($spreadsheet, 'Ods'),
'xlsx' => \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($spreadsheet, 'Xlsx'),
'csv' => \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($spreadsheet, 'Csv'),
default => throw new \LogicException(),
};
$writer->save($this->tempfile);
$writer->save($tempfile);
}
/**
@@ -357,7 +311,7 @@ class SpreadSheetFormatter implements FormatterInterface
*
* @return string[] an array containing the keys of aggregators
*/
protected function getAggregatorKeysSorted()
private function getAggregatorKeysSorted(array $aggregatorsData, array $formatterData)
{
// empty array for aggregators keys
$keys = [];
@@ -365,7 +319,7 @@ class SpreadSheetFormatter implements FormatterInterface
// during sorting
$aggregatorKeyAssociation = [];
foreach ($this->aggregatorsData as $alias => $data) {
foreach ($aggregatorsData as $alias => $data) {
$aggregator = $this->exportManager->getAggregator($alias);
$aggregatorsKeys = $aggregator->getQueryKeys($data);
// append the keys from aggregator to the $keys existing array
@@ -377,9 +331,9 @@ class SpreadSheetFormatter implements FormatterInterface
}
// sort the result using the form
usort($keys, function ($a, $b) use ($aggregatorKeyAssociation) {
$A = $this->formatterData[$aggregatorKeyAssociation[$a]]['order'];
$B = $this->formatterData[$aggregatorKeyAssociation[$b]]['order'];
usort($keys, function ($a, $b) use ($aggregatorKeyAssociation, $formatterData) {
$A = $formatterData[$aggregatorKeyAssociation[$a]]['order'];
$B = $formatterData[$aggregatorKeyAssociation[$b]]['order'];
if ($A === $B) {
return 0;
@@ -395,7 +349,7 @@ class SpreadSheetFormatter implements FormatterInterface
return $keys;
}
protected function getContentType($format)
private function getContentType($format)
{
switch ($format) {
case 'csv':
@@ -412,25 +366,26 @@ class SpreadSheetFormatter implements FormatterInterface
/**
* Get the displayable result.
*
* @param string $key
*
* @return string
*/
protected function getDisplayableResult($key, mixed $value): mixed
{
if (false === $this->cacheDisplayableResultIsInitialized) {
$this->initializeCache($key);
}
private function getDisplayableResult(
string $key,
mixed $value,
array $cacheDisplayableResult,
): string|TranslatableInterface|\DateTimeInterface|int|float|bool {
$value ??= '';
return \call_user_func($this->cacheDisplayableResult[$key], $value);
return \call_user_func($cacheDisplayableResult[$key], $value);
}
protected function getTitle()
private function getTitle($export): string
{
$title = $this->translator->trans($this->export->getTitle());
$original = $export->getTitle();
if ($original instanceof TranslatableInterface) {
$title = $original->trans($this->translator, $this->translator->getLocale());
} else {
$title = $this->translator->trans($original);
}
if (30 < strlen($title)) {
return substr($title, 0, 30).'…';
@@ -439,8 +394,13 @@ class SpreadSheetFormatter implements FormatterInterface
return $title;
}
protected function initializeCache($key)
{
private function initializeDisplayable(
$result,
ExportInterface $export,
array $exportData,
array $aggregatorsData,
): array {
$cacheDisplayableResult = [];
/*
* this function follows the following steps :
*
@@ -453,13 +413,12 @@ class SpreadSheetFormatter implements FormatterInterface
// 1. create an associative array with key and export / aggregator
$keysExportElementAssociation = [];
// keys for export
foreach ($this->export->getQueryKeys($this->exportData) as $key) {
$keysExportElementAssociation[$key] = [$this->export,
$this->exportData, ];
foreach ($export->getQueryKeys($exportData) as $key) {
$keysExportElementAssociation[$key] = [$export, $exportData];
}
// keys for aggregator
foreach ($this->aggregatorsData as $alias => $data) {
$aggregator = $this->exportManager->getAggregator($alias);
foreach ($aggregatorsData as $alias => $data) {
$aggregator = $this->getExportManager()->getAggregator($alias);
foreach ($aggregator->getQueryKeys($data) as $key) {
$keysExportElementAssociation[$key] = [$aggregator, $data];
@@ -471,7 +430,7 @@ class SpreadSheetFormatter implements FormatterInterface
$allValues = [];
// store all the values in an array
foreach ($this->result as $row) {
foreach ($result as $row) {
foreach ($keys as $key) {
$allValues[$key][] = $row[$key];
}
@@ -482,15 +441,14 @@ class SpreadSheetFormatter implements FormatterInterface
foreach ($keysExportElementAssociation as $key => [$element, $data]) {
// handle the case when there is not results lines (query is empty)
if ([] === $allValues) {
$this->cacheDisplayableResult[$key] = $element->getLabels($key, ['_header'], $data);
$cacheDisplayableResult[$key] = $element->getLabels($key, ['_header'], $data);
} else {
$this->cacheDisplayableResult[$key] =
$cacheDisplayableResult[$key] =
$element->getLabels($key, \array_unique($allValues[$key]), $data);
}
}
// the cache is initialized !
$this->cacheDisplayableResultIsInitialized = true;
return $cacheDisplayableResult;
}
/**
@@ -528,23 +486,28 @@ class SpreadSheetFormatter implements FormatterInterface
* )
* ```
*/
protected function sortResult()
{
private function sortResult(
$result,
ExportInterface $export,
array $exportData,
array $aggregatorsData,
array $formatterData,
array $cacheDisplayableResult,
) {
// get the keys for each row
$exportKeys = $this->export->getQueryKeys($this->exportData);
$aggregatorKeys = $this->getAggregatorKeysSorted();
$exportKeys = $export->getQueryKeys($exportData);
$aggregatorKeys = $this->getAggregatorKeysSorted($aggregatorsData, $formatterData);
$globalKeys = \array_merge($aggregatorKeys, $exportKeys);
$sortedResult = \array_map(function ($row) use ($globalKeys) {
$sortedResult = \array_map(function ($row) use ($globalKeys, $cacheDisplayableResult) {
$newRow = [];
foreach ($globalKeys as $key) {
$newRow[] = $this->getDisplayableResult($key, $row[$key]);
$newRow[] = $this->getDisplayableResult($key, $row[$key], $cacheDisplayableResult);
}
return $newRow;
}, $this->result);
}, $result);
\array_multisort($sortedResult);

View File

@@ -11,67 +11,40 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export\Formatter;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Export\ExportGenerationContext;
use Chill\MainBundle\Export\ExportManagerAwareInterface;
use Chill\MainBundle\Export\FormattedExportGeneration;
use Chill\MainBundle\Export\FormatterInterface;
use Chill\MainBundle\Export\Helper\ExportManagerAwareTrait;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function count;
// command to get the report with curl : curl --user "center a_social:password" "http://localhost:8000/fr/exports/generate/count_person?export[filters][person_gender_filter][enabled]=&export[filters][person_nationality_filter][enabled]=&export[filters][person_nationality_filter][form][nationalities]=&export[aggregators][person_nationality_aggregator][order]=1&export[aggregators][person_nationality_aggregator][form][group_by_level]=country&export[submit]=&export[_token]=RHpjHl389GrK-bd6iY5NsEqrD5UKOTHH40QKE9J1edU" --globoff
/**
* Create a CSV List for the export.
*/
class SpreadsheetListFormatter implements FormatterInterface
class SpreadsheetListFormatter implements FormatterInterface, ExportManagerAwareInterface
{
protected $exportAlias;
use ExportManagerAwareTrait;
protected $exportData;
/**
* @var ExportManager
*/
protected $exportManager;
protected $formatterData;
/**
* This variable cache the labels internally.
*
* @var string[]
*/
protected $labelsCache;
protected $result;
/**
* @var TranslatorInterface
*/
protected $translator;
public function __construct(TranslatorInterface $translatorInterface, ExportManager $exportManager)
{
$this->translator = $translatorInterface;
$this->exportManager = $exportManager;
}
public function __construct(private readonly TranslatorInterface $translator) {}
/**
* build a form, which will be used to collect data required for the execution
* of this formatter.
*
* @uses appendAggregatorForm
*
* @param string $exportAlias
*/
public function buildForm(
FormBuilderInterface $builder,
$exportAlias,
string $exportAlias,
array $aggregatorAliases,
): void {
$builder
@@ -93,58 +66,52 @@ class SpreadsheetListFormatter implements FormatterInterface
]);
}
public function getNormalizationVersion(): int
{
return 1;
}
public function normalizeFormData(array $formData): array
{
return ['format' => $formData['format'], 'numerotation' => $formData['numerotation']];
}
public function denormalizeFormData(array $formData, int $fromVersion): array
{
return ['format' => $formData['format'], 'numerotation' => $formData['numerotation']];
}
public function getFormDefaultData(array $aggregatorAliases): array
{
return ['numerotation' => true, 'format' => 'xlsx'];
}
public function getName()
public function getName(): string|TranslatableInterface
{
return 'Spreadsheet list formatter (.xlsx, .ods)';
}
/**
* Generate a response from the data collected on differents ExportElementInterface.
*
* @param mixed[] $result The result, as given by the ExportInterface
* @param mixed[] $formatterData collected from the current form
* @param string $exportAlias the id of the current export
* @param array $filtersData an array containing the filters data. The key are the filters id, and the value are the data
* @param array $aggregatorsData an array containing the aggregators data. The key are the filters id, and the value are the data
*
* @return Response The response to be shown
*/
public function getResponse(
$result,
$formatterData,
$exportAlias,
array $exportData,
array $filtersData,
array $aggregatorsData,
): \Symfony\Component\HttpFoundation\Response {
$this->result = $result;
$this->exportAlias = $exportAlias;
$this->exportData = $exportData;
$this->formatterData = $formatterData;
public function generate($result, $formatterData, string $exportAlias, array $exportData, array $filtersData, array $aggregatorsData, ExportGenerationContext $context): FormattedExportGeneration
{
$spreadsheet = new Spreadsheet();
$worksheet = $spreadsheet->getActiveSheet();
$cacheLabels = $this->prepareCacheLabels($result, $exportAlias, $exportData);
$this->prepareHeaders($worksheet);
$this->prepareHeaders($cacheLabels, $worksheet, $result, $formatterData, $exportAlias, $exportData);
$i = 1;
foreach ($result as $row) {
if (true === $this->formatterData['numerotation']) {
if (true === $formatterData['numerotation']) {
$worksheet->setCellValue('A'.($i + 1), (string) $i);
}
$a = $this->formatterData['numerotation'] ? 'B' : 'A';
$a = $formatterData['numerotation'] ? 'B' : 'A';
foreach ($row as $key => $value) {
$row = $a.($i + 1);
$formattedValue = $this->getLabel($key, $value);
$formattedValue = $this->getLabel($cacheLabels, $key, $value, $result, $exportAlias, $exportData);
if ($formattedValue instanceof \DateTimeInterface) {
$worksheet->setCellValue($row, Date::PHPToExcel($formattedValue));
@@ -158,6 +125,8 @@ class SpreadsheetListFormatter implements FormatterInterface
->getNumberFormat()
->setFormatCode(NumberFormat::FORMAT_DATE_DATETIME);
}
} elseif ($formattedValue instanceof TranslatableInterface) {
$worksheet->setCellValue($row, $formattedValue->trans($this->translator));
} else {
$worksheet->setCellValue($row, $formattedValue);
}
@@ -167,7 +136,7 @@ class SpreadsheetListFormatter implements FormatterInterface
++$i;
}
switch ($this->formatterData['format']) {
switch ($formatterData['format']) {
case 'ods':
$writer = \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($spreadsheet, 'Ods');
$contentType = 'application/vnd.oasis.opendocument.spreadsheet';
@@ -190,26 +159,52 @@ class SpreadsheetListFormatter implements FormatterInterface
default:
// this should not happen
// throw an exception to ensure that the error is catched
throw new \OutOfBoundsException('The format '.$this->formatterData['format'].' is not supported');
throw new \OutOfBoundsException('The format '.$formatterData['format'].' is not supported');
}
$response = new Response();
$response->headers->set('content-type', $contentType);
$tempfile = \tempnam(\sys_get_temp_dir(), '');
$writer->save($tempfile);
$f = \fopen($tempfile, 'rb');
$response->setContent(\stream_get_contents($f));
fclose($f);
$generated = new FormattedExportGeneration(
file_get_contents($tempfile),
$contentType,
);
// remove the temp file from disk
\unlink($tempfile);
return $generated;
}
/**
* Generate a response from the data collected on differents ExportElementInterface.
*
* @param mixed[] $result The result, as given by the ExportInterface
* @param mixed[] $formatterData collected from the current form
* @param string $exportAlias the id of the current export
* @param array $aggregatorsData an array containing the aggregators data. The key are the filters id, and the value are the data
* @param array $filtersData an array containing the filters data. The key are the filters id, and the value are the data
*
* @return Response The response to be shown
*/
public function getResponse(
$result,
$formatterData,
$exportAlias,
array $exportData,
array $filtersData,
array $aggregatorsData,
ExportGenerationContext $context,
) {
$generated = $this->generate($result, $formatterData, $exportAlias, $exportData, $filtersData, $aggregatorsData, $context);
$response = new BinaryFileResponse($generated->content);
$response->headers->set('Content-Type', $generated->contentType);
return $response;
}
public function getType()
public function getType(): string
{
return FormatterInterface::TYPE_LIST;
}
@@ -217,34 +212,29 @@ class SpreadsheetListFormatter implements FormatterInterface
/**
* Give the label corresponding to the given key and value.
*
* @param string $key
* @param string $value
*
* @return string
* @return string|\DateTimeInterface|int|float|TranslatableInterface|null
*
* @throws \LogicException if the label is not found
*/
protected function getLabel($key, $value)
private function getLabel(array $labelsCache, $key, $value, array $result, string $exportAlias, array $exportData)
{
if (null === $this->labelsCache) {
$this->prepareCacheLabels();
if (!\array_key_exists($key, $labelsCache)) {
throw new \OutOfBoundsException(sprintf('The key "%s" is not present in the list of keys handled by this query. Check your `getKeys` and `getLabels` methods. Available keys are %s.', $key, \implode(', ', \array_keys($labelsCache))));
}
if (!\array_key_exists($key, $this->labelsCache)) {
throw new \OutOfBoundsException(sprintf('The key "%s" is not present in the list of keys handled by this query. Check your `getKeys` and `getLabels` methods. Available keys are %s.', $key, \implode(', ', \array_keys($this->labelsCache))));
}
return $this->labelsCache[$key]($value);
return $labelsCache[$key]($value);
}
/**
* Prepare the label cache which will be used by getLabel. This function
* should be called only once in the generation lifecycle.
* Prepare the label cache which will be used by getLabel.
*
* @return array The labels cache
*/
protected function prepareCacheLabels()
private function prepareCacheLabels(array $result, string $exportAlias, array $exportData): array
{
$export = $this->exportManager->getExport($this->exportAlias);
$keys = $export->getQueryKeys($this->exportData);
$labelsCache = [];
$export = $this->getExportManager()->getExport($exportAlias);
$keys = $export->getQueryKeys($exportData);
foreach ($keys as $key) {
// get an array with all values for this key if possible
@@ -254,29 +244,31 @@ class SpreadsheetListFormatter implements FormatterInterface
}
return $v[$key];
}, $this->result);
// store the label in the labelsCache property
$this->labelsCache[$key] = $export->getLabels($key, $values, $this->exportData);
}, $result);
// store the label in the labelsCache
$labelsCache[$key] = $export->getLabels($key, $values, $exportData);
}
return $labelsCache;
}
/**
* add the headers to the csv file.
*/
protected function prepareHeaders(Worksheet $worksheet)
protected function prepareHeaders(array $labelsCache, Worksheet $worksheet, array $result, array $formatterData, string $exportAlias, array $exportData)
{
$keys = $this->exportManager->getExport($this->exportAlias)->getQueryKeys($this->exportData);
$keys = $this->getExportManager()->getExport($exportAlias)->getQueryKeys($exportData);
// we want to keep the order of the first row. So we will iterate on the first row of the results
$first_row = \count($this->result) > 0 ? $this->result[0] : [];
$first_row = \count($result) > 0 ? $result[0] : [];
$header_line = [];
if (true === $this->formatterData['numerotation']) {
if (true === $formatterData['numerotation']) {
$header_line[] = $this->translator->trans('Number');
}
foreach ($first_row as $key => $value) {
$header_line[] = $this->translator->trans(
$this->getLabel($key, '_header')
$this->getLabel($labelsCache, $key, '_header', $result, $exportAlias, $exportData)
);
}

View File

@@ -12,7 +12,11 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Contracts\Translation\TranslatableInterface;
/**
* @method generate($result, $formatterData, string $exportAlias, array $exportData, array $filtersData, array $aggregatorsData, ExportGenerationContext $context): FormattedExportGeneration
*/
interface FormatterInterface
{
public const TYPE_LIST = 'list';
@@ -30,16 +34,16 @@ interface FormatterInterface
*/
public function buildForm(
FormBuilderInterface $builder,
$exportAlias,
string $exportAlias,
array $aggregatorAliases,
);
): void;
/**
* get the default data for the form build by buildForm.
*/
public function getFormDefaultData(array $aggregatorAliases): array;
public function getName();
public function getName(): string|TranslatableInterface;
/**
* Generate a response from the data collected on differents ExportElementInterface.
@@ -47,19 +51,28 @@ interface FormatterInterface
* @param mixed[] $result The result, as given by the ExportInterface
* @param mixed[] $formatterData collected from the current form
* @param string $exportAlias the id of the current export
* @param array $filtersData an array containing the filters data. The key are the filters id, and the value are the data
* @param array $aggregatorsData an array containing the aggregators data. The key are the filters id, and the value are the data
* @param array $filtersData an array containing the filters data. The key are the filters id, and the value are the data
*
* @return \Symfony\Component\HttpFoundation\Response The response to be shown
*
* @deprecated use generate instead
*/
public function getResponse(
$result,
$formatterData,
$exportAlias,
array $result,
array $formatterData,
string $exportAlias,
array $exportData,
array $filtersData,
array $aggregatorsData,
ExportGenerationContext $context,
);
public function getType();
public function getType(): string;
public function normalizeFormData(array $formData): array;
public function denormalizeFormData(array $formData, int $fromVersion): array;
public function getNormalizationVersion(): int;
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export\Helper;
use Chill\MainBundle\Export\Exception\ExportRuntimeException;
use Chill\MainBundle\Export\ExportManager;
trait ExportManagerAwareTrait
{
private ?ExportManager $exportManager;
public function setExportManager(ExportManager $exportManager): void
{
$this->exportManager = $exportManager;
}
public function getExportManager(): ExportManager
{
if (null === $this->exportManager) {
throw new ExportRuntimeException('ExportManager not set');
}
return $this->exportManager;
}
}

View File

@@ -11,6 +11,9 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Doctrine\ORM\NativeQuery;
use Doctrine\ORM\QueryBuilder;
/**
* Define methods to export list.
*
@@ -19,5 +22,10 @@ namespace Chill\MainBundle\Export;
* (and list does not support aggregation on their data).
*
* When used, the `ExportManager` will not handle aggregator for this class.
*
* @template Q of QueryBuilder|NativeQuery
* @template D of array
*
* @template-extends ExportInterface<Q, D>
*/
interface ListInterface extends ExportInterface {}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export\Messenger;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Entity\User;
use Ramsey\Uuid\UuidInterface;
final readonly class ExportRequestGenerationMessage
{
public UuidInterface $id;
public int $userId;
public function __construct(
ExportGeneration $exportGeneration,
User $user,
) {
$this->id = $exportGeneration->getId();
$this->userId = $user->getId();
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export\Messenger;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Export\ExportGenerator;
use Chill\MainBundle\Repository\ExportGenerationRepository;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
#[AsMessageHandler]
final readonly class ExportRequestGenerationMessageHandler implements MessageHandlerInterface
{
private const LOG_PREFIX = '[export_generation] ';
public function __construct(
private ExportGenerationRepository $repository,
private UserRepositoryInterface $userRepository,
private ExportGenerator $exportGenerator,
private StoredObjectManagerInterface $storedObjectManager,
private EntityManagerInterface $entityManager,
private LoggerInterface $logger,
) {}
public function __invoke(ExportRequestGenerationMessage $exportRequestGenerationMessage)
{
$start = microtime(true);
$this->logger->info(
self::LOG_PREFIX.'Handle generation message',
[
'exportId' => (string) $exportRequestGenerationMessage->id,
]
);
if (null === $exportGeneration = $this->repository->find($exportRequestGenerationMessage->id)) {
throw new \UnexpectedValueException('ExportRequestGenerationMessage not found');
}
if (null === $user = $this->userRepository->find($exportRequestGenerationMessage->userId)) {
throw new \UnexpectedValueException('User not found');
}
if (StoredObject::STATUS_PENDING !== $exportGeneration->getStatus()) {
throw new UnrecoverableMessageHandlingException('object already generated');
}
$generated = $this->exportGenerator->generate($exportGeneration->getExportAlias(), $exportGeneration->getOptions(), $user);
$this->storedObjectManager->write($exportGeneration->getStoredObject(), $generated->content, $generated->contentType);
$exportGeneration->getStoredObject()->setStatus(StoredObject::STATUS_READY);
$this->entityManager->flush();
$end = microtime(true);
$this->logger->notice(self::LOG_PREFIX.'Export generation successfully finished', [
'exportId' => (string) $exportRequestGenerationMessage->id,
'exportAlias' => $exportGeneration->getExportAlias(),
'full_generation_duration' => $end - $exportGeneration->getCreatedAt()->getTimestamp(),
'message_handler_duration' => $end - $start,
]);
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export\Messenger;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Repository\ExportGenerationRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
class OnExportGenerationFails implements EventSubscriberInterface
{
private const LOG_PREFIX = '[export_generation failed] ';
public function __construct(
private readonly LoggerInterface $logger,
private readonly ExportGenerationRepository $repository,
private readonly EntityManagerInterface $entityManager,
) {}
public static function getSubscribedEvents()
{
return [
WorkerMessageFailedEvent::class => 'onMessageFailed',
];
}
public function onMessageFailed(WorkerMessageFailedEvent $event): void
{
if ($event->willRetry()) {
return;
}
$message = $event->getEnvelope()->getMessage();
if (!$message instanceof ExportRequestGenerationMessage) {
return;
}
if (null === $exportGeneration = $this->repository->find($message->id)) {
throw new \UnexpectedValueException('ExportRequestGenerationMessage not found');
}
$this->logger->error(self::LOG_PREFIX.'ExportRequestGenerationMessage failed to execute generation', [
'exportId' => (string) $message->id,
'userId' => $message->userId,
'alias' => $exportGeneration->getExportAlias(),
'throwable_message' => $event->getThrowable()->getMessage(),
'throwable_trace' => $event->getThrowable()->getTraceAsString(),
'throwable' => $event->getThrowable()::class,
'full_generation_duration_failure' => microtime(true) - $exportGeneration->getCreatedAt()->getTimestamp(),
]);
$this->markObjectAsFailed($event, $exportGeneration);
$this->entityManager->flush();
}
private function markObjectAsFailed(WorkerMessageFailedEvent $event, ExportGeneration $exportGeneration): void
{
$exportGeneration->getStoredObject()->addGenerationErrors($event->getThrowable()->getMessage());
$exportGeneration->getStoredObject()->setStatus(StoredObject::STATUS_FAILURE);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export\Messenger;
use Chill\MainBundle\Entity\ExportGeneration;
final readonly class RemoveExportGenerationMessage
{
public string $exportGenerationId;
public function __construct(ExportGeneration $exportGeneration)
{
$this->exportGenerationId = $exportGeneration->getId()->toString();
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export\Messenger;
use Chill\MainBundle\Repository\ExportGenerationRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
#[AsMessageHandler]
final readonly class RemoveExportGenerationMessageHandler implements MessageHandlerInterface
{
private const LOG_PREFIX = '[RemoveExportGenerationMessageHandler] ';
public function __construct(
private ExportGenerationRepository $exportGenerationRepository,
private EntityManagerInterface $entityManager,
private LoggerInterface $logger,
private ClockInterface $clock,
) {}
public function __invoke(RemoveExportGenerationMessage $message): void
{
$exportGeneration = $this->exportGenerationRepository->find($message->exportGenerationId);
if (null === $exportGeneration) {
$this->logger->error(self::LOG_PREFIX.'ExportGeneration not found');
throw new UnrecoverableMessageHandlingException(self::LOG_PREFIX.'ExportGeneration not found');
}
$storedObject = $exportGeneration->getStoredObject();
$storedObject->setDeleteAt($this->clock->now());
$this->entityManager->remove($exportGeneration);
$this->entityManager->flush();
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export\Migrator;
use Chill\MainBundle\Service\RollingDate\RollingDate;
class SavedExportOptionsMigrator
{
public static function migrate(array $fromOptions): array
{
$to = [];
$to['aggregators'] = array_map(
self::mapEnabledStatus(...),
$fromOptions['export']['export']['aggregators'] ?? [],
);
$to['filters'] = array_map(
self::mapEnabledStatus(...),
$fromOptions['export']['export']['filters'] ?? [],
);
$to['export'] = [
'form' => array_map(
self::mapFormData(...),
array_filter(
$fromOptions['export']['export']['export'] ?? [],
static fn (string $key) => !in_array($key, ['filters', 'aggregators', 'pick_formatter'], true),
ARRAY_FILTER_USE_KEY,
),
),
'version' => 1,
];
$to['pick_formatter'] = $fromOptions['export']['export']['pick_formatter']['alias'] ?? null;
$to['centers'] = [
'centers' => array_values(array_map(static fn ($id) => (int) $id, $fromOptions['centers']['centers']['c'] ?? $fromOptions['centers']['centers']['center'] ?? [])),
'regroupments' => array_values(array_map(static fn ($id) => (int) $id, $fromOptions['centers']['centers']['regroupment'] ?? [])),
];
$to['formatter'] = [
'form' => $fromOptions['formatter']['formatter'] ?? [],
'version' => 1,
];
return $to;
}
private static function mapEnabledStatus(array $modifiersData): array
{
if ('1' === ($modifiersData['enabled'] ?? '0')) {
return [
'form' => array_map(self::mapFormData(...), $modifiersData['form'] ?? []),
'version' => 1,
'enabled' => true,
];
}
return ['enabled' => false];
}
private static function mapFormData(array|string $formData): array|string|null
{
if (is_array($formData) && array_key_exists('roll', $formData)) {
return self::refactorRollingDate($formData);
}
if (is_string($formData)) {
// we try different date formats
if (false !== \DateTimeImmutable::createFromFormat('d-m-Y', $formData)) {
return $formData;
}
if (false !== \DateTimeImmutable::createFromFormat('Y-m-d', $formData)) {
return $formData;
}
// we try json content
try {
$data = json_decode($formData, true, 512, JSON_THROW_ON_ERROR);
if (is_array($data)) {
if (array_key_exists('type', $data) && array_key_exists('id', $data) && in_array($data['type'], ['person', 'thirdParty', 'user'], true)) {
return $data['id'];
}
$response = [];
foreach ($data as $item) {
if (array_key_exists('type', $item) && array_key_exists('id', $item) && in_array($item['type'], ['person', 'thirdParty', 'user'], true)) {
$response[] = $item['id'];
}
}
if ([] !== $response) {
return $response;
}
}
} catch (\JsonException) {
return $formData;
}
}
return $formData;
}
private static function refactorRollingDate(array $formData): ?array
{
if ('' === $formData['roll']) {
return null;
}
$fixedDate = null !== ($formData['fixedDate'] ?? null) && '' !== $formData['fixedDate'] ?
\DateTimeImmutable::createFromFormat('Y-m-d H:i:s', sprintf('%s 00:00:00', $formData['fixedDate']), new \DateTimeZone(date_default_timezone_get())) : null;
return (new RollingDate(
$formData['roll'],
$fixedDate,
))->normalize();
}
}

View File

@@ -37,12 +37,12 @@ interface ModifierInterface extends ExportElementInterface
* @param QueryBuilder $qb the QueryBuilder initiated by the Export (and eventually modified by other Modifiers)
* @param mixed[] $data the data from the Form (builded by buildForm)
*/
public function alterQuery(QueryBuilder $qb, $data);
public function alterQuery(QueryBuilder $qb, $data, ExportGenerationContext $exportGenerationContext): void;
/**
* On which type of Export this ModifiersInterface may apply.
*
* @return string the type on which the Modifiers apply
*/
public function applyOn();
public function applyOn(): string;
}

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class SortExportElement
@@ -19,12 +20,21 @@ final readonly class SortExportElement
private TranslatorInterface $translator,
) {}
private function trans(string|TranslatableInterface $message): string
{
if ($message instanceof TranslatableInterface) {
return $message->trans($this->translator, $this->translator->getLocale());
}
return $this->translator->trans($message);
}
/**
* @param array<int|string, FilterInterface> $elements
*/
public function sortFilters(array &$elements): void
{
uasort($elements, fn (FilterInterface $a, FilterInterface $b) => $this->translator->trans($a->getTitle()) <=> $this->translator->trans($b->getTitle()));
uasort($elements, fn (FilterInterface $a, FilterInterface $b) => $this->trans($a->getTitle()) <=> $this->trans($b->getTitle()));
}
/**
@@ -32,6 +42,6 @@ final readonly class SortExportElement
*/
public function sortAggregators(array &$elements): void
{
uasort($elements, fn (AggregatorInterface $a, AggregatorInterface $b) => $this->translator->trans($a->getTitle()) <=> $this->translator->trans($b->getTitle()));
uasort($elements, fn (AggregatorInterface $a, AggregatorInterface $b) => $this->trans($a->getTitle()) <=> $this->trans($b->getTitle()));
}
}

View File

@@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form\DataMapper;
use Chill\MainBundle\Entity\Regroupment;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\FormInterface;
final readonly class ExportPickCenterDataMapper implements DataMapperInterface
{
public function mapDataToForms($viewData, \Traversable $forms): void
{
if (null === $viewData) {
return;
}
/** @var array<string, FormInterface> $form */
$form = iterator_to_array($forms);
$form['center']->setData($viewData);
// NOTE: we do not map back the regroupments
}
public function mapFormsToData(\Traversable $forms, &$viewData): void
{
/** @var array<string, FormInterface> $forms */
$forms = iterator_to_array($forms);
$centers = [];
foreach ($forms['center']->getData() as $center) {
$centers[spl_object_hash($center)] = $center;
}
if (\array_key_exists('regroupment', $forms)) {
/** @var Regroupment $regroupment */
foreach ($forms['regroupment']->getData() as $regroupment) {
foreach ($regroupment->getCenters() as $center) {
$centers[spl_object_hash($center)] = $center;
}
}
}
$viewData = array_values($centers);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form\DataMapper;
use Chill\MainBundle\Entity\User;
use Symfony\Component\Form\DataMapperInterface;
final readonly class NotificationFlagDataMapper implements DataMapperInterface
{
public function __construct(private array $notificationFlagProviders) {}
public function mapDataToForms($viewData, $forms): void
{
if (null === $viewData) {
$viewData = [];
}
$formsArray = iterator_to_array($forms);
foreach ($this->notificationFlagProviders as $flagProvider) {
$flag = $flagProvider->getFlag();
if (isset($formsArray[$flag])) {
$flagForm = $formsArray[$flag];
$immediateEmailChecked = in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $viewData[$flag] ?? [], true)
|| !array_key_exists($flag, $viewData);
$dailyEmailChecked = in_array(User::NOTIF_FLAG_DAILY_DIGEST, $viewData[$flag] ?? [], true);
if ($flagForm->has('immediate_email')) {
$flagForm->get('immediate_email')->setData($immediateEmailChecked);
}
if ($flagForm->has('daily_email')) {
$flagForm->get('daily_email')->setData($dailyEmailChecked);
}
}
}
}
public function mapFormsToData($forms, &$viewData): void
{
$formsArray = iterator_to_array($forms);
$viewData = [];
foreach ($this->notificationFlagProviders as $flagProvider) {
$flag = $flagProvider->getFlag();
if (isset($formsArray[$flag])) {
$flagForm = $formsArray[$flag];
$viewData[$flag] = [];
if (true === $flagForm['immediate_email']->getData()) {
$viewData[$flag][] = User::NOTIF_FLAG_IMMEDIATE_EMAIL;
}
if (true === $flagForm['daily_email']->getData()) {
$viewData[$flag][] = User::NOTIF_FLAG_DAILY_DIGEST;
}
if ([] === $viewData[$flag]) {
$viewData[$flag][] = User::NOTIF_FLAG_IMMEDIATE_EMAIL;
}
}
}
}
}

View File

@@ -12,17 +12,12 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Form\Type\ChillCollectionType;
use Chill\MainBundle\Form\Type\ChillTextareaType;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Form\Type\PickUserGroupOrUserDynamicType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotNull;
class NotificationType extends AbstractType
{
@@ -33,29 +28,14 @@ class NotificationType extends AbstractType
'label' => 'Title',
'required' => true,
])
->add('addressees', PickUserDynamicType::class, [
->add('addressees', PickUserGroupOrUserDynamicType::class, [
'multiple' => true,
'required' => false,
'label' => 'notification.Pick user or user group',
'empty_data' => '[]',
'required' => true,
])
->add('message', ChillTextareaType::class, [
'required' => false,
])
->add('addressesEmails', ChillCollectionType::class, [
'label' => 'notification.dest by email',
'help' => 'notification.dest by email help',
'by_reference' => false,
'allow_add' => true,
'allow_delete' => true,
'entry_type' => EmailType::class,
'button_add_label' => 'notification.Add an email',
'button_remove_label' => 'notification.Remove an email',
'empty_collection_explain' => 'notification.Any email',
'entry_options' => [
'constraints' => [
new NotNull(), new NotBlank(), new Email(),
],
'label' => 'Email',
],
]);
}

View File

@@ -13,6 +13,9 @@ namespace Chill\MainBundle\Form;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Form\Type\ChillTextareaType;
use Chill\MainBundle\Form\Type\PickUserGroupOrUserDynamicType;
use Chill\MainBundle\Security\Authorization\SavedExportVoter;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -20,8 +23,12 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class SavedExportType extends AbstractType
{
public function __construct(private readonly Security $security) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$savedExport = $options['data'];
$builder
->add('title', TextType::class, [
'required' => true,
@@ -29,6 +36,14 @@ class SavedExportType extends AbstractType
->add('description', ChillTextareaType::class, [
'required' => false,
]);
if ($this->security->isGranted(SavedExportVoter::SHARE, $savedExport)) {
$builder->add('share', PickUserGroupOrUserDynamicType::class, [
'multiple' => true,
'required' => false,
'label' => 'saved_export.Share',
]);
}
}
public function configureOptions(OptionsResolver $resolver): void

View File

@@ -26,7 +26,7 @@ class EntityToJsonTransformer implements DataTransformerInterface
{
public function __construct(private readonly DenormalizerInterface $denormalizer, private readonly SerializerInterface $serializer, private readonly bool $multiple, private readonly string $type) {}
public function reverseTransform($value): mixed
public function reverseTransform($value)
{
if ('' === $value) {
return $this->multiple ? [] : null;
@@ -66,8 +66,11 @@ class EntityToJsonTransformer implements DataTransformerInterface
]);
}
private function denormalizeOne(array $item): mixed
private function denormalizeOne(array|string $item)
{
if ('me' === $item) {
return $item;
}
if (!\array_key_exists('type', $item)) {
throw new TransformationFailedException('the key "type" is missing on element');
}
@@ -98,5 +101,6 @@ class EntityToJsonTransformer implements DataTransformerInterface
'json',
$context,
);
}
}

View File

@@ -21,15 +21,18 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class AggregatorType extends AbstractType
{
public const ENABLED_FIELD = 'enabled';
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$exportManager = $options['export_manager'];
$aggregator = $exportManager->getAggregator($options['aggregator_alias']);
$builder
->add('enabled', CheckboxType::class, [
->add(self::ENABLED_FIELD, CheckboxType::class, [
'value' => true,
'required' => false,
'disabled' => $options['disable_enable_field'],
]);
$aggregatorFormBuilder = $builder->create('form', FormType::class, [
@@ -53,6 +56,7 @@ class AggregatorType extends AbstractType
{
$resolver->setRequired('aggregator_alias')
->setRequired('export_manager')
->setDefault('disable_enable_field', false)
->setDefault('compound', true)
->setDefault('error_bubbling', false);
}

View File

@@ -35,7 +35,7 @@ class ExportType extends AbstractType
public function __construct(
private readonly ExportManager $exportManager,
private readonly SortExportElement $sortExportElement,
protected ParameterBagInterface $parameterBag,
ParameterBagInterface $parameterBag,
) {
$this->personFieldsConfig = $parameterBag->get('chill_person.person_fields');
}
@@ -43,6 +43,8 @@ class ExportType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$export = $this->exportManager->getExport($options['export_alias']);
/** @var bool $canEditFull */
$canEditFull = $options['can_edit_full'];
$exportOptions = [
'compound' => true,
@@ -59,8 +61,18 @@ class ExportType extends AbstractType
if ($export instanceof \Chill\MainBundle\Export\ExportInterface) {
// add filters
$filters = $this->exportManager->getFiltersApplyingOn($export, $options['picked_centers']);
$filterAliases = $options['allowed_filters'];
$filters = [];
if (is_iterable($filterAliases)) {
foreach ($filterAliases as $alias => $filter) {
$filters[$alias] = $filter;
}
} else {
$filters = $this->exportManager->getFiltersApplyingOn($export, $options['picked_centers']);
}
$this->sortExportElement->sortFilters($filters);
$filterBuilder = $builder->create(self::FILTER_KEY, FormType::class, ['compound' => true]);
foreach ($filters as $alias => $filter) {
@@ -70,15 +82,26 @@ class ExportType extends AbstractType
'constraints' => [
new ExportElementConstraint(['element' => $filter]),
],
'disable_enable_field' => !$canEditFull,
]);
}
$builder->add($filterBuilder);
// add aggregators
$aggregators = $this->exportManager
->getAggregatorsApplyingOn($export, $options['picked_centers']);
$aggregatorsAliases = $options['allowed_aggregators'];
$aggregators = [];
if (is_iterable($aggregatorsAliases)) {
foreach ($aggregatorsAliases as $alias => $aggregator) {
$aggregators[$alias] = $aggregator;
}
} else {
$aggregators = $this->exportManager
->getAggregatorsApplyingOn($export, $options['picked_centers']);
}
$this->sortExportElement->sortAggregators($aggregators);
$aggregatorBuilder = $builder->create(
self::AGGREGATOR_KEY,
FormType::class,
@@ -96,11 +119,11 @@ class ExportType extends AbstractType
}
}
$aggregatorBuilder->add($alias, AggregatorType::class, [
'aggregator_alias' => $alias,
'export_manager' => $this->exportManager,
'label' => $aggregator->getTitle(),
'disable_enable_field' => !$canEditFull,
'constraints' => [
new ExportElementConstraint(['element' => $aggregator]),
],
@@ -125,8 +148,13 @@ class ExportType extends AbstractType
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setRequired(['export_alias', 'picked_centers'])
$resolver->setRequired(['export_alias', 'picked_centers', 'can_edit_full'])
->setAllowedTypes('export_alias', ['string'])
->setAllowedValues('can_edit_full', [true, false])
->setDefault('allowed_filters', null)
->setAllowedTypes('allowed_filters', ['iterable', 'null'])
->setDefault('allowed_aggregators', null)
->setAllowedTypes('allowed_aggregators', ['iterable', 'null'])
->setDefault('compound', true)
->setDefault('constraints', [
// new \Chill\MainBundle\Validator\Constraints\Export\ExportElementConstraint()

View File

@@ -34,6 +34,7 @@ class FilterType extends AbstractType
->add(self::ENABLED_FIELD, CheckboxType::class, [
'value' => true,
'required' => false,
'disabled' => $options['disable_enable_field'],
]);
$filterFormBuilder = $builder->create('form', FormType::class, [
@@ -58,6 +59,7 @@ class FilterType extends AbstractType
$resolver
->setRequired('filter')
->setAllowedTypes('filter', [FilterInterface::class])
->setDefault('disable_enable_field', false)
->setDefault('compound', true)
->setDefault('error_bubbling', false);
}

View File

@@ -14,9 +14,9 @@ namespace Chill\MainBundle\Form\Type\Export;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Regroupment;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Form\DataMapper\ExportPickCenterDataMapper;
use Chill\MainBundle\Repository\RegroupmentRepository;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface;
use Chill\MainBundle\Service\Regroupement\RegroupementFiltering;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -27,27 +27,26 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
final class PickCenterType extends AbstractType
{
public const CENTERS_IDENTIFIERS = 'c';
public function __construct(
private readonly ExportManager $exportManager,
private readonly RegroupmentRepository $regroupmentRepository,
private readonly AuthorizationHelperForCurrentUserInterface $authorizationHelper,
private readonly RegroupementFiltering $regroupementFiltering,
) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$export = $this->exportManager->getExport($options['export_alias']);
$centers = $this->authorizationHelper->getReachableCenters(
$export->requiredRole()
$export->requiredRole(),
);
$centersActive = array_filter($centers, fn (Center $c) => $c->getIsActive());
// order alphabetically
usort($centersActive, fn (Center $a, Center $b) => $a->getCenter() <=> $b->getName());
usort($centersActive, fn (Center $a, Center $b) => $a->getName() <=> $b->getName());
$builder->add('center', EntityType::class, [
$builder->add('centers', EntityType::class, [
'class' => Center::class,
'choices' => $centersActive,
'label' => 'center',
@@ -56,18 +55,22 @@ final class PickCenterType extends AbstractType
'choice_label' => static fn (Center $c) => $c->getName(),
]);
if (\count($this->regroupmentRepository->findAllActive()) > 0) {
$builder->add('regroupment', EntityType::class, [
$groups = $this->regroupementFiltering
->filterContainsAtLeastOneCenter($this->regroupmentRepository->findAllActive(), $centersActive);
// order alphabetically
usort($groups, fn (Regroupment $a, Regroupment $b) => $a->getName() <=> $b->getName());
if (\count($groups) > 0) {
$builder->add('regroupments', EntityType::class, [
'class' => Regroupment::class,
'label' => 'regroupment',
'multiple' => true,
'expanded' => true,
'choices' => $this->regroupmentRepository->findAllActive(),
'choices' => $groups,
'choice_label' => static fn (Regroupment $r) => $r->getName(),
]);
}
$builder->setDataMapper(new ExportPickCenterDataMapper());
}
public function configureOptions(OptionsResolver $resolver): void

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form\Type;
use Chill\MainBundle\Form\DataMapper\NotificationFlagDataMapper;
use Chill\MainBundle\Notification\NotificationFlagManager;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class NotificationFlagsType extends AbstractType
{
private readonly array $notificationFlagProviders;
public function __construct(NotificationFlagManager $notificationFlagManager)
{
$this->notificationFlagProviders = $notificationFlagManager->getAllNotificationFlagProviders();
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->setDataMapper(new NotificationFlagDataMapper($this->notificationFlagProviders));
foreach ($this->notificationFlagProviders as $flagProvider) {
$flag = $flagProvider->getFlag();
$builder->add($flag, FormType::class, [
'label' => $flagProvider->getLabel(),
'required' => false,
]);
$builder->get($flag)
->add('immediate_email', CheckboxType::class, [
'label' => false,
'required' => false,
'mapped' => false,
])
->add('daily_email', CheckboxType::class, [
'label' => false,
'required' => false,
'mapped' => false,
])
;
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => null,
]);
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form\Type;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\Type\DataTransformer\EntityToJsonTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Pick user dymically, using vuejs module "AddPerson".
*
* Possible options:
*
* - `multiple`: pick one or more users
* - `suggested`: a list of suggested users
* - `suggest_myself`: append the current user to the list of suggested
* - `as_id`: only the id will be set in the returned data
* - `submit_on_adding_new_entity`: the browser will immediately submit the form when new users are checked
*/
class PickUserOrMeDynamicType extends AbstractType
{
public function __construct(
private readonly DenormalizerInterface $denormalizer,
private readonly SerializerInterface $serializer,
private readonly NormalizerInterface $normalizer,
) {}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addViewTransformer(new EntityToJsonTransformer($this->denormalizer, $this->serializer, $options['multiple'], 'user'));
}
public function buildView(FormView $view, FormInterface $form, array $options)
{
$view->vars['multiple'] = $options['multiple'];
$view->vars['types'] = ['user'];
$view->vars['uniqid'] = uniqid('pick_user_or_me_dyn');
$view->vars['suggested'] = [];
$view->vars['as_id'] = true === $options['as_id'] ? '1' : '0';
$view->vars['submit_on_adding_new_entity'] = true === $options['submit_on_adding_new_entity'] ? '1' : '0';
foreach ($options['suggested'] as $user) {
$view->vars['suggested'][] = $this->normalizer->normalize($user, 'json', ['groups' => 'read']);
}
// $user = /* should come from context */ $options['context'];
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver
->setDefault('multiple', false)
->setAllowedTypes('multiple', ['bool'])
->setDefault('compound', false)
->setDefault('suggested', [])
// if set to true, only the id will be set inside the content. The denormalization will not work.
->setDefault('as_id', false)
->setAllowedTypes('as_id', ['bool'])
->setDefault('submit_on_adding_new_entity', false)
->setAllowedTypes('submit_on_adding_new_entity', ['bool']);
}
public function getBlockPrefix()
{
return 'pick_entity_dynamic';
}
}

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Form\Type\TranslatableStringFormType;
use Symfony\Component\Form\AbstractType;
@@ -23,6 +24,9 @@ class UserGroupType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
/** @var UserGroup $userGroup */
$userGroup = $options['data'];
$builder
->add('label', TranslatableStringFormType::class, [
'label' => 'user_group.Label',
@@ -46,20 +50,25 @@ class UserGroupType extends AbstractType
'help' => 'user_group.ExcludeKeyHelp',
'required' => false,
'empty_data' => '',
])
->add('users', PickUserDynamicType::class, [
'label' => 'user_group.Users',
'multiple' => true,
'required' => false,
'empty_data' => [],
])
->add('adminUsers', PickUserDynamicType::class, [
'label' => 'user_group.adminUsers',
'multiple' => true,
'required' => false,
'empty_data' => [],
'help' => 'user_group.adminUsersHelp',
])
;
]);
if (!$userGroup->hasUserJob()) {
$builder
->add('users', PickUserDynamicType::class, [
'label' => 'user_group.Users',
'multiple' => true,
'required' => false,
'empty_data' => [],
])
->add('adminUsers', PickUserDynamicType::class, [
'label' => 'user_group.adminUsers',
'multiple' => true,
'required' => false,
'empty_data' => [],
'help' => 'user_group.adminUsersHelp',
])
;
}
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form;
use Chill\MainBundle\Form\Type\ChillPhoneNumberType;
use Chill\MainBundle\Form\Type\NotificationFlagsType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class UserProfileType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('phonenumber', ChillPhoneNumberType::class, [
'required' => false,
])
->add('notificationFlags', NotificationFlagsType::class, [
'label' => false,
'mapped' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => \Chill\MainBundle\Entity\User::class,
]);
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Notification\Email;
use Chill\MainBundle\Cron\CronJobInterface;
use Chill\MainBundle\Entity\CronJobExecution;
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\MessageBusInterface;
readonly class DailyNotificationDigestCronjob implements CronJobInterface
{
public function __construct(
private ClockInterface $clock,
private Connection $connection,
private MessageBusInterface $messageBus,
private LoggerInterface $logger,
) {}
public function canRun(?CronJobExecution $cronJobExecution): bool
{
$now = $this->clock->now();
if (null !== $cronJobExecution && $now->sub(new \DateInterval('PT23H45M')) < $cronJobExecution->getLastStart()) {
return false;
}
// Run between 6 and 9 AM
return in_array((int) $now->format('H'), [6, 7, 8], true);
}
public function getKey(): string
{
return 'daily-notification-digest';
}
/**
* @throws \DateInvalidOperationException
* @throws Exception
*/
public function run(array $lastExecutionData): ?array
{
$now = $this->clock->now();
if (isset($lastExecutionData['last_execution'])) {
$lastExecution = \DateTimeImmutable::createFromFormat(
\DateTimeImmutable::ATOM,
$lastExecutionData['last_execution']
);
} else {
$lastExecution = $now->sub(new \DateInterval('P1D'));
}
// Get distinct users who received notifications since the last execution
$sql = <<<'SQL'
SELECT DISTINCT cmnau.user_id
FROM chill_main_notification cmn
JOIN chill_main_notification_addresses_user cmnau ON cmnau.notification_id = cmn.id
WHERE cmn.date >= :lastExecution AND cmn.date <= :now
SQL;
$sqlStatement = $this->connection->prepare($sql);
$sqlStatement->bindValue('lastExecution', $lastExecution->format(\DateTimeInterface::RFC3339));
$sqlStatement->bindValue('now', $now->format(\DateTimeInterface::RFC3339));
$result = $sqlStatement->executeQuery();
$count = 0;
foreach ($result->fetchAllAssociative() as $row) {
$userId = (int) $row['user_id'];
$message = new ScheduleDailyNotificationDigestMessage(
$userId,
$lastExecution,
$now
);
$this->messageBus->dispatch($message);
++$count;
}
$this->logger->info('[DailyNotificationDigestCronjob] Dispatched daily digest messages', [
'user_count' => $count,
'last_execution' => $lastExecution->format('Y-m-d-H:i:s.u e'),
'current_time' => $now->format('Y-m-d-H:i:s.u e'),
]);
return [
'last_execution' => $now->format('Y-m-d-H:i:s.u e'),
];
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Notification\Email\NotificationEmailHandlers;
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage;
use Chill\MainBundle\Notification\Email\NotificationMailer;
use Chill\MainBundle\Repository\NotificationRepository;
use Chill\MainBundle\Repository\UserRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class ScheduleDailyNotificationDigestHandler
{
public function __construct(
private NotificationRepository $notificationRepository,
private UserRepository $userRepository,
private NotificationMailer $notificationMailer,
private LoggerInterface $logger,
) {}
/**
* @throws TransportExceptionInterface
*/
public function __invoke(ScheduleDailyNotificationDigestMessage $message): void
{
$userId = $message->getUserId();
$lastExecutionDate = $message->getLastExecutionDateTime();
$currentDate = $message->getCurrentDateTime();
$user = $this->userRepository->find($userId);
if (null === $user) {
$this->logger->warning('[ScheduleDailyNotificationDigestHandler] User not found', [
'user_id' => $userId,
]);
throw new \InvalidArgumentException(sprintf('User with ID %s not found', $userId));
}
// Get all notifications for this user between last execution and current date
$notifications = $this->notificationRepository->findNotificationsForUserBetweenDates(
$userId,
$lastExecutionDate,
$currentDate
);
// Filter out notifications that should be sent in a daily digest
$dailyNotifications = array_filter($notifications, fn ($notification) => $user->isNotificationDailyDigest($notification->getType()));
if ([] === $dailyNotifications) {
$this->logger->info('[ScheduleDailyNotificationDigestHandler] No daily notifications found for user', [
'user_id' => $userId,
]);
return;
}
$this->notificationMailer->sendDailyDigest($user, $dailyNotifications);
$this->logger->info('[ScheduleDailyNotificationDigestHandler] Sent daily digest', [
'user_id' => $userId,
'notification_count' => count($dailyNotifications),
]);
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Notification\Email\NotificationEmailHandlers;
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage;
use Chill\MainBundle\Notification\Email\NotificationMailer;
use Chill\MainBundle\Repository\NotificationRepository;
use Chill\MainBundle\Repository\UserRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class SendImmediateNotificationEmailHandler
{
public function __construct(
private NotificationRepository $notificationRepository,
private UserRepository $userRepository,
private NotificationMailer $notificationMailer,
private LoggerInterface $logger,
) {}
/**
* @throws TransportExceptionInterface
* @throws \Exception
*/
public function __invoke(SendImmediateNotificationEmailMessage $message): void
{
$notification = $this->notificationRepository->find($message->getNotificationId());
$addressee = $this->userRepository->find($message->getAddresseeId());
if (null === $notification) {
$this->logger->error('[SendImmediateNotificationEmailHandler] Notification not found', [
'notification_id' => $message->getNotificationId(),
]);
throw new \InvalidArgumentException(sprintf('Notification with ID %s not found', $message->getNotificationId()));
}
if (null === $addressee) {
$this->logger->error('[SendImmediateNotificationEmailHandler] Addressee not found', [
'addressee_id' => $message->getAddresseeId(),
]);
throw new \InvalidArgumentException(sprintf('User with ID %s not found', $message->getAddresseeId()));
}
try {
$this->notificationMailer->sendEmailToAddressee($notification, $addressee);
} catch (\Exception $e) {
$this->logger->error('[SendImmediateNotificationEmailHandler] Failed to send email', [
'notification_id' => $message->getNotificationId(),
'addressee_id' => $message->getAddresseeId(),
'stacktrace' => $e->getTraceAsString(),
]);
throw $e;
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Notification\Email\NotificationEmailMessages;
readonly class ScheduleDailyNotificationDigestMessage
{
public function __construct(
private int $userId,
private \DateTimeInterface $lastExecutionDate,
private \DateTimeInterface $currentDate,
) {}
public function getUserId(): int
{
return $this->userId;
}
public function getLastExecutionDateTime(): \DateTimeInterface
{
return $this->lastExecutionDate;
}
public function getCurrentDateTime(): \DateTimeInterface
{
return $this->currentDate;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Notification\Email\NotificationEmailMessages;
readonly class SendImmediateNotificationEmailMessage
{
public function __construct(
private int $notificationId,
private int $addresseeId,
) {}
public function getNotificationId(): int
{
return $this->notificationId;
}
public function getAddresseeId(): int
{
return $this->addresseeId;
}
}

View File

@@ -13,22 +13,32 @@ namespace Chill\MainBundle\Notification\Email;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\NotificationComment;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage;
use Doctrine\ORM\Event\PostPersistEventArgs;
use Doctrine\ORM\Event\PostUpdateEventArgs;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class NotificationMailer
readonly class NotificationMailer
{
public function __construct(private readonly MailerInterface $mailer, private readonly LoggerInterface $logger, private readonly TranslatorInterface $translator) {}
public function __construct(
private MailerInterface $mailer,
private LoggerInterface $logger,
private MessageBusInterface $messageBus,
private TranslatorInterface $translator,
) {}
public function postPersistComment(NotificationComment $comment, PostPersistEventArgs $eventArgs): void
{
$dests = [$comment->getNotification()->getSender(), ...$comment->getNotification()->getAddressees()->toArray()];
$dests = [
$comment->getNotification()->getSender(),
...$comment->getNotification()->getAddressees()->toArray(),
];
$uniqueDests = [];
foreach ($dests as $dest) {
@@ -69,55 +79,147 @@ class NotificationMailer
*/
public function postPersistNotification(Notification $notification, PostPersistEventArgs $eventArgs): void
{
$this->sendNotificationEmailsToAddresses($notification);
$this->sendNotificationEmailsToAddressees($notification);
$this->sendNotificationEmailsToAddressesEmails($notification);
}
public function postUpdateNotification(Notification $notification, PostUpdateEventArgs $eventArgs): void
private function sendNotificationEmailsToAddressees(Notification $notification): void
{
$this->sendNotificationEmailsToAddressesEmails($notification);
}
if ('' === $notification->getType()) {
$this->logger->warning('[NotificationMailer] Notification has no type, skipping email processing', [
'notification_id' => $notification->getId(),
]);
private function sendNotificationEmailsToAddresses(Notification $notification): void
{
foreach ($notification->getAddressees() as $addressee) {
return;
}
foreach ($notification->getAllAddressees() as $addressee) {
if (null === $addressee->getEmail()) {
continue;
}
if ($notification->isSystem()) {
$email = new Email();
$email
->text($notification->getMessage());
} else {
$email = new TemplatedEmail();
$email
->textTemplate('@ChillMain/Notification/email_non_system_notification_content.fr.md.twig')
->context([
'notification' => $notification,
'dest' => $addressee,
]);
}
$this->processNotificationForAddressee($notification, $addressee);
}
}
private function processNotificationForAddressee(Notification $notification, User $addressee): void
{
$notificationType = $notification->getType();
if ($addressee->isNotificationSendImmediately($notificationType)) {
$this->scheduleImmediateEmail($notification, $addressee);
}
}
private function scheduleImmediateEmail(Notification $notification, User $addressee): void
{
$message = new SendImmediateNotificationEmailMessage(
$notification->getId(),
$addressee->getId()
);
$this->messageBus->dispatch($message);
$this->logger->info('[NotificationMailer] Scheduled immediate email', [
'notification_id' => $notification->getId(),
'addressee_email' => $addressee->getEmail(),
]);
}
/**
* This method sends the email but is now called by the immediate notification email message handler.
*
* @throws TransportExceptionInterface
*/
public function sendEmailToAddressee(Notification $notification, User $addressee): void
{
if (null === $addressee->getEmail()) {
return;
}
if ($notification->isSystem()) {
$email = new Email();
$email->text($notification->getMessage());
} else {
$email = new TemplatedEmail();
$email
->subject($notification->getTitle())
->to($addressee->getEmail());
try {
$this->mailer->send($email);
} catch (TransportExceptionInterface $e) {
$this->logger->warning('[NotificationMailer] could not send an email notification', [
'to' => $addressee->getEmail(),
'error_message' => $e->getMessage(),
'error_trace' => $e->getTraceAsString(),
->textTemplate('@ChillMain/Notification/email_non_system_notification_content.fr.md.twig')
->context([
'notification' => $notification,
'dest' => $addressee,
]);
}
}
$email
->subject($notification->getTitle())
->to($addressee->getEmail());
try {
$this->mailer->send($email);
$this->logger->info('[NotificationMailer] Email sent successfully', [
'notification_id' => $notification->getId(),
'addressee_email' => $addressee->getEmail(),
]);
} catch (TransportExceptionInterface $e) {
$this->logger->warning('[NotificationMailer] Could not send an email notification', [
'to' => $addressee->getEmail(),
'notification_id' => $notification->getId(),
'error_message' => $e->getMessage(),
'error_trace' => $e->getTraceAsString(),
]);
throw $e;
}
}
/**
* Send daily digest email with multiple notifications to a user.
*
* @throws TransportExceptionInterface
*/
public function sendDailyDigest(User $user, array $notifications): void
{
if (null === $user->getEmail() || [] === $notifications) {
return;
}
$email = new TemplatedEmail();
$email
->htmlTemplate('@ChillMain/Notification/email_daily_digest.fr.md.twig')
->context([
'user' => $user,
'notifications' => $notifications,
'notification_count' => count($notifications),
])
->subject($this->translator->trans('notification.Daily Notification Digest'))
->to($user->getEmail());
try {
$this->mailer->send($email);
$this->logger->info('[NotificationMailer] Daily digest email sent successfully', [
'user_email' => $user->getEmail(),
'notification_count' => count($notifications),
]);
} catch (TransportExceptionInterface $e) {
$this->logger->warning('[NotificationMailer] Could not send daily digest email', [
'to' => $user->getEmail(),
'notification_count' => count($notifications),
'error_message' => $e->getMessage(),
'error_trace' => $e->getTraceAsString(),
]);
throw $e;
}
}
private function sendNotificationEmailsToAddressesEmails(Notification $notification): void
{
foreach ($notification->getAddressesEmailsAdded() as $emailAddress) {
foreach ($notification->getAddresseeUserGroups() as $userGroup) {
if (!$userGroup->hasEmail()) {
continue;
}
$emailAddress = $userGroup->getEmail();
$email = new TemplatedEmail();
$email
->textTemplate('@ChillMain/Notification/email_non_system_notification_content_to_email.fr.md.twig')

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Notification\FlagProviders;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
class NotificationByUserFlagProvider implements NotificationFlagProviderInterface
{
public const FLAG = 'notif-by-user';
public function getFlag(): string
{
return self::FLAG;
}
public function getLabel(): TranslatableInterface
{
return new TranslatableMessage('notification.flags.by-user');
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Notification\FlagProviders;
use Symfony\Contracts\Translation\TranslatableInterface;
interface NotificationFlagProviderInterface
{
public function getFlag(): string;
public function getLabel(): TranslatableInterface;
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Notification\FlagProviders;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
class WorkflowTransitionNotificationFlagProvider implements NotificationFlagProviderInterface
{
public const FLAG = 'workflow-trans-notif';
public function getFlag(): string
{
return self::FLAG;
}
public function getLabel(): TranslatableInterface
{
return new TranslatableMessage('notification.flags.workflow-trans');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Notification;
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
final readonly class NotificationFlagManager
{
/**
* @var array<NotificationFlagProviderInterface>
*/
private array $notificationFlagProviders;
public function __construct(
iterable $notificationFlagProviders,
) {
$this->notificationFlagProviders = iterator_to_array($notificationFlagProviders);
}
public function getAllNotificationFlagProviders(): array
{
return $this->notificationFlagProviders;
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Repository;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ExportGeneration>
*
* @implements AssociatedEntityToStoredObjectInterface<ExportGeneration>
*/
class ExportGenerationRepository extends ServiceEntityRepository implements AssociatedEntityToStoredObjectInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ExportGeneration::class);
}
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?ExportGeneration
{
return $this->createQueryBuilder('e')
->where('e.storedObject = :storedObject')
->setParameter('storedObject', $storedObject)
->getQuery()
->getOneOrNullResult();
}
/**
* @return list<ExportGeneration>
*/
public function findExportGenerationByAliasAndUser(string $alias, User $user, int $limit = 100, int $offset = 0): array
{
return $this->createQueryBuilder('e')
->where('e.createdBy = :user')
->andWhere('e.exportAlias LIKE :alias')
->orderBy('e.createdAt', 'DESC')
->setParameter('user', $user)
->setParameter('alias', $alias)
->setFirstResult($offset)
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* @return list<ExportGeneration>
*/
public function findExportGenerationBySavedExportAndUser(SavedExport $savedExport, User $user, int $limit = 100, int $offset = 0): array
{
return $this->createQueryBuilder('e')
->where('e.createdBy = :user')
->andWhere('e.savedExport = :savedExport')
->orderBy('e.createdAt', 'DESC')
->setParameter('user', $user)
->setParameter('savedExport', $savedExport)
->setFirstResult($offset)
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function findExpiredExportGeneration(\DateTimeImmutable $atDate): iterable
{
return $this->createQueryBuilder('e')
->where('e.deleteAt < :atDate')
->setParameter('atDate', $atDate)
->getQuery()
->toIterable();
}
}

View File

@@ -13,6 +13,7 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Entity\GeographicalUnit;
use Chill\MainBundle\Entity\GeographicalUnit\SimpleGeographicalUnitDTO;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\Expr\Join;
@@ -42,7 +43,7 @@ final readonly class GeographicalUnitRepository implements GeographicalUnitRepos
$qb = $this->buildQueryGeographicalUnitContainingAddress($address);
return $qb
->select(sprintf('NEW %s(gu.id, gu.unitName, gu.unitRefId, IDENTITY(gu.layer))', GeographicalUnit\SimpleGeographicalUnitDTO::class))
->select(sprintf('NEW %s(gu.id, gu.unitName, gu.unitRefId, IDENTITY(gu.layer))', SimpleGeographicalUnitDTO::class))
->addOrderBy('IDENTITY(gu.layer)')
->addOrderBy('gu.unitName')
->getQuery()
@@ -58,7 +59,7 @@ final readonly class GeographicalUnitRepository implements GeographicalUnitRepos
;
return $qb
->select(sprintf('NEW %s(gu.id, gu.unitName, gu.unitRefId, IDENTITY(gu.layer))', GeographicalUnit\SimpleGeographicalUnitDTO::class))
->select(sprintf('NEW %s(gu.id, gu.unitName, gu.unitRefId, IDENTITY(gu.layer))', SimpleGeographicalUnitDTO::class))
->innerJoin(Address::class, 'address', Join::WITH, 'ST_CONTAINS(gu.geom, address.point) = TRUE')
->where($qb->expr()->eq('address', ':address'))
->setParameter('address', $address)
@@ -70,6 +71,19 @@ final readonly class GeographicalUnitRepository implements GeographicalUnitRepos
return $this->repository->find($id);
}
public function findSimpleGeographicalUnit(int $id): ?SimpleGeographicalUnitDTO
{
$qb = $this->repository
->createQueryBuilder('gu');
return $qb
->select(sprintf('NEW %s(gu.id, gu.unitName, gu.unitRefId, IDENTITY(gu.layer))', SimpleGeographicalUnitDTO::class))
->where('gu.id = :id')
->setParameter('id', $id)
->getQuery()
->getOneOrNullResult();
}
/**
* Will return only partial object, where the @see{GeographicalUnit::geom} property is not loaded.
*
@@ -79,7 +93,7 @@ final readonly class GeographicalUnitRepository implements GeographicalUnitRepos
{
return $this->repository
->createQueryBuilder('gu')
->select(sprintf('NEW %s(gu.id, gu.unitName, gu.unitRefId, IDENTITY(gu.layer))', GeographicalUnit\SimpleGeographicalUnitDTO::class))
->select(sprintf('NEW %s(gu.id, gu.unitName, gu.unitRefId, IDENTITY(gu.layer))', SimpleGeographicalUnitDTO::class))
->addOrderBy('IDENTITY(gu.layer)')
->addOrderBy('gu.unitName')
->getQuery()

View File

@@ -27,4 +27,6 @@ interface GeographicalUnitRepositoryInterface extends ObjectRepository
public function findGeographicalUnitContainingAddress(Address $address, int $offset = 0, int $limit = 50): array;
public function countGeographicalUnitContainingAddress(Address $address): int;
public function findSimpleGeographicalUnit(int $id): ?SimpleGeographicalUnitDTO;
}

View File

@@ -290,12 +290,19 @@ final class NotificationRepository implements ObjectRepository
return $qb;
}
private function queryByAddressee(User $addressee, bool $countQuery = false): QueryBuilder
private function queryByAddressee(User $addressee): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('n');
$qb
->where($qb->expr()->isMemberOf(':addressee', 'n.addressees'))
->leftJoin('n.addresseeUserGroups', 'aug')
->leftJoin('aug.users', 'ugu')
->where(
$qb->expr()->orX(
$qb->expr()->isMemberOf(':addressee', 'n.addressees'),
$qb->expr()->eq('ugu.id', ':addressee')
)
)
->setParameter('addressee', $addressee);
return $qb;
@@ -393,4 +400,30 @@ final class NotificationRepository implements ObjectRepository
return $nq->getResult();
}
/**
* Find all notifications for a user that were created between two dates.
*
* @return array|Notification[]
*/
public function findNotificationsForUserBetweenDates(int $userId, \DateTimeInterface $startDate, \DateTimeInterface $endDate): array
{
$rsm = new Query\ResultSetMappingBuilder($this->em);
$rsm->addRootEntityFromClassMetadata(Notification::class, 'cmn');
$sql = 'SELECT '.$rsm->generateSelectClause(['cmn' => 'cmn']).' '.
'FROM chill_main_notification cmn '.
'JOIN chill_main_notification_addresses_user cmnau ON cmnau.notification_id = cmn.id '.
'WHERE cmnau.user_id = :userId '.
'AND cmn.date >= :startDate '.
'AND cmn.date <= :endDate '.
'ORDER BY cmn.date DESC';
$nq = $this->em->createNativeQuery($sql, $rsm)
->setParameter('userId', $userId)
->setParameter('startDate', $startDate, Types::DATETIME_MUTABLE)
->setParameter('endDate', $endDate, Types::DATETIME_MUTABLE);
return $nq->getResult();
}
}

View File

@@ -16,9 +16,8 @@ use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\Persistence\ObjectRepository;
final readonly class RegroupmentRepository implements ObjectRepository
final readonly class RegroupmentRepository implements RegroupmentRepositoryInterface
{
private EntityRepository $repository;

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\Regroupment;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\Persistence\ObjectRepository;
/**
* @template-extends ObjectRepository<Regroupment>
*/
interface RegroupmentRepositoryInterface extends ObjectRepository
{
/**
* @throws NonUniqueResultException
* @throws NoResultException
*/
public function findOneByName(string $name): ?Regroupment;
/**
* @return array<Regroupment>
*/
public function findRegroupmentAssociatedToNoCenter(): array;
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Entity\SavedExport;
readonly class SavedExportOrExportGenerationRepository
{
public function __construct(
private SavedExportRepositoryInterface $savedExportRepository,
private ExportGenerationRepository $exportGenerationRepository,
) {}
public function findById(string $uuid): SavedExport|ExportGeneration|null
{
if (null !== $savedExport = $this->savedExportRepository->find($uuid)) {
return $savedExport;
}
return $this->exportGenerationRepository->find($uuid);
}
}

View File

@@ -13,9 +13,12 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\String\UnicodeString;
/**
* @implements ObjectRepository<SavedExport>
@@ -55,6 +58,51 @@ class SavedExportRepository implements SavedExportRepositoryInterface
->where($qb->expr()->eq('se.user', ':user'))
->setParameter('user', $user);
return $this->prepareResult($qb, $orderBy, $limit, $offset);
}
public function findSharedWithUser(User $user, ?array $orderBy = [], ?int $limit = null, ?int $offset = null, array $filters = []): array
{
$qb = $this->repository->createQueryBuilder('se');
$qb
->where(
$qb->expr()->orX(
$qb->expr()->eq('se.user', ':user'),
$qb->expr()->isMemberOf(':user', 'se.sharedWithUsers'),
$qb->expr()->exists(
sprintf('SELECT 1 FROM %s ug WHERE ug MEMBER OF se.sharedWithGroups AND :user MEMBER OF ug.users', UserGroup::class)
)
)
)
->setParameter('user', $user);
foreach ($filters as $key => $filter) {
if (self::FILTER_TITLE === ($key & self::FILTER_TITLE)
|| self::FILTER_DESCRIPTION === ($key & self::FILTER_DESCRIPTION)) {
$filter = new UnicodeString($filter);
$i = 0;
foreach ($filter->split(' ') as $word) {
$orx = $qb->expr()->orX();
if (self::FILTER_TITLE === ($key & self::FILTER_TITLE)) {
$orx->add($qb->expr()->like('LOWER(se.title)', 'LOWER(:qs'.$i.')'));
}
if (self::FILTER_DESCRIPTION === ($key & self::FILTER_DESCRIPTION)) {
$orx->add($qb->expr()->like('LOWER(se.description)', 'LOWER(:qs'.$i.')'));
}
$qb->andWhere($orx);
$qb->setParameter('qs'.$i, '%'.$word->trim().'%');
++$i;
}
}
}
return $this->prepareResult($qb, $orderBy, $limit, $offset);
}
private function prepareResult(QueryBuilder $qb, ?array $orderBy = [], ?int $limit = null, ?int $offset = null): array
{
if (null !== $limit) {
$qb->setMaxResults($limit);
}

View File

@@ -20,6 +20,9 @@ use Doctrine\Persistence\ObjectRepository;
*/
interface SavedExportRepositoryInterface extends ObjectRepository
{
public const FILTER_TITLE = 0x01;
public const FILTER_DESCRIPTION = 0x10;
public function find($id): ?SavedExport;
/**
@@ -34,6 +37,15 @@ interface SavedExportRepositoryInterface extends ObjectRepository
*/
public function findByUser(User $user, ?array $orderBy = [], ?int $limit = null, ?int $offset = null): array;
/**
* Get the saved export created by and the user and the ones shared with the user.
*
* @param array<int, mixed> $filters filters where keys are one of the constant starting with FILTER_
*
* @return list<SavedExport>
*/
public function findSharedWithUser(User $user, ?array $orderBy = [], ?int $limit = null, ?int $offset = null, array $filters = []): array;
public function findOneBy(array $criteria): ?SavedExport;
public function getClassName(): string;

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\ORM\EntityManagerInterface;
@@ -30,9 +31,6 @@ readonly class UserJobRepository implements UserJobRepositoryInterface
return $this->repository->find($id);
}
/**
* @return array|UserJob[]
*/
public function findAll(): array
{
return $this->repository->findAll();
@@ -56,12 +54,20 @@ readonly class UserJobRepository implements UserJobRepositoryInterface
return $jobs;
}
/**
* @param mixed|null $limit
* @param mixed|null $offset
*
* @return array|object[]|UserJob[]
*/
public function findAllNotAssociatedWithUserGroup(): array
{
$qb = $this->repository->createQueryBuilder('u');
$qb->select('u');
$qb->where(
$qb->expr()->not(
$qb->expr()->exists(sprintf('SELECT 1 FROM %s ug WHERE ug.userJob = u', UserGroup::class))
)
);
return $qb->getQuery()->getResult();
}
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null)
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);

View File

@@ -14,18 +14,15 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\UserJob;
use Doctrine\Persistence\ObjectRepository;
/**
* @template-extends ObjectRepository<UserJob>
*/
interface UserJobRepositoryInterface extends ObjectRepository
{
public function find($id): ?UserJob;
/**
* @return array|UserJob[]
*/
public function findAll(): array;
/**
* @return array|UserJob[]
*/
public function findAllActive(): array;
/**
@@ -36,11 +33,14 @@ interface UserJobRepositoryInterface extends ObjectRepository
public function findAllOrderedByName(): array;
/**
* @param mixed|null $limit
* @param mixed|null $offset
* Find all the user job which are not related to a UserGroup.
*
* @return array|object[]|UserJob[]
* This is useful for synchronizing UserGroups with jobs.
*
* @return list<UserJob>
*/
public function findAllNotAssociatedWithUserGroup(): array;
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null);
public function findOneBy(array $criteria): ?UserJob;

View File

@@ -61,6 +61,9 @@ export interface ConflictHttpExceptionInterface
/**
* Generic api method that can be adapted to any fetch request
*
* This method is suitable make a single fetch. When performing a GET to fetch a list of elements, always consider pagination
* and use of the @link{fetchResults} method.
*/
export const makeFetch = <Input, Output>(
method: "POST" | "GET" | "PUT" | "PATCH" | "DELETE",

View File

@@ -0,0 +1,18 @@
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
import { ExportGeneration } from "ChillMainAssets/types";
export const fetchExportGenerationStatus = async (
exportGenerationId: string,
): Promise<ExportGeneration> =>
makeFetch(
"GET",
`/api/1.0/main/export-generation/${exportGenerationId}/object`,
);
export const generateFromSavedExport = async (
savedExportUuid: string,
): Promise<ExportGeneration> =>
makeFetch(
"POST",
`/api/1.0/main/export/export-generation/create-from-saved-export/${savedExportUuid}`,
);

View File

@@ -0,0 +1,3 @@
export function buildReturnPath(location: Location): string {
return location.pathname + location.search;
}

View File

@@ -12,6 +12,11 @@ function loadDynamicPicker(element) {
let apps = element.querySelectorAll('[data-module="pick-dynamic"]');
apps.forEach(function (el) {
let suggested;
let as_id;
let submit_on_adding_new_entity;
let label;
let isCurrentUserPicker;
const isMultiple = parseInt(el.dataset.multiple) === 1,
uniqId = el.dataset.uniqid,
input = element.querySelector(
@@ -22,12 +27,13 @@ function loadDynamicPicker(element) {
? JSON.parse(input.value)
: input.value === "[]" || input.value === ""
? null
: [JSON.parse(input.value)],
suggested = JSON.parse(el.dataset.suggested),
as_id = parseInt(el.dataset.asId) === 1,
submit_on_adding_new_entity =
parseInt(el.dataset.submitOnAddingNewEntity) === 1,
label = el.dataset.label;
: [JSON.parse(input.value)];
suggested = JSON.parse(el.dataset.suggested);
as_id = parseInt(el.dataset.asId) === 1;
submit_on_adding_new_entity =
parseInt(el.dataset.submitOnAddingNewEntity) === 1;
label = el.dataset.label;
isCurrentUserPicker = uniqId.startsWith("pick_user_or_me_dyn");
if (!isMultiple) {
if (input.value === "[]") {
@@ -44,6 +50,7 @@ function loadDynamicPicker(element) {
':uniqid="uniqid" ' +
':suggested="notPickedSuggested" ' +
':label="label" ' +
':isCurrentUserPicker="isCurrentUserPicker" ' +
'@addNewEntity="addNewEntity" ' +
'@removeEntity="removeEntity" ' +
'@addNewEntityProcessEnded="addNewEntityProcessEnded"' +
@@ -61,6 +68,7 @@ function loadDynamicPicker(element) {
as_id,
submit_on_adding_new_entity,
label,
isCurrentUserPicker,
};
},
computed: {
@@ -89,7 +97,8 @@ function loadDynamicPicker(element) {
const ids = this.picked.map((el) => el.id);
input.value = ids.join(",");
}
console.log(entity);
console.log(this.picked);
// console.log(entity);
}
} else {
if (

View File

@@ -1,14 +0,0 @@
import { download_report } from "../../lib/download-report/download-report";
window.addEventListener("DOMContentLoaded", function (e) {
const export_generate_url = window.export_generate_url;
if (typeof export_generate_url === "undefined") {
console.error("Alias not found!");
throw new Error("Alias not found!");
}
const query = window.location.search,
container = document.querySelector("#download_container");
download_report(export_generate_url + query.toString(), container);
});

View File

@@ -1,4 +1,5 @@
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
export interface DateTime {
datetime: string;
@@ -200,3 +201,17 @@ export interface WorkflowAttachment {
updatedBy: User | null;
genericDoc: null | GenericDoc;
}
export interface ExportGeneration {
id: string;
type: "export_generation";
exportAlias: string;
createdBy: User | null;
createdAt: DateTime | null;
status: StoredObjectStatus;
storedObject: StoredObject;
}
export interface PrivateCommentEmbeddable {
comments: Record<number, string>;
}

View File

@@ -0,0 +1,141 @@
<script setup lang="ts">
import {
trans,
EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING,
EXPORT_GENERATION_TOO_MANY_RETRIES,
EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT,
EXPORT_GENERATION_EXPORT_READY,
} from "translator";
import { computed, onMounted, ref } from "vue";
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
import { fetchExportGenerationStatus } from "ChillMainAssets/lib/api/export";
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
import { ExportGeneration } from "ChillMainAssets/types";
interface AppProps {
exportGenerationId: string;
title: string;
createdDate: string;
}
const props = defineProps<AppProps>();
const exportGeneration = ref<ExportGeneration | null>(null);
const status = computed<StoredObjectStatus>(
() => exportGeneration.value?.status ?? "pending",
);
const storedObject = computed<null | StoredObject>(() => {
if (exportGeneration.value === null) {
return null;
}
return exportGeneration.value?.storedObject;
});
const isPending = computed<boolean>(() => status.value === "pending");
const isFetching = computed<boolean>(
() => tryiesForReady.value < maxTryiesForReady,
);
const isReady = computed<boolean>(() => status.value === "ready");
const isFailure = computed<boolean>(() => status.value === "failure");
const filename = computed<string>(() => `${props.title}-${props.createdDate}`);
/**
* counter for the number of times that we check for a new status
*/
let tryiesForReady = ref<number>(0);
/**
* how many times we may check for a new status, once loaded
*/
const maxTryiesForReady = 120;
const checkForReady = function (): void {
if (
"ready" === status.value ||
"empty" === status.value ||
"failure" === status.value ||
// stop reloading if the page stays opened for a long time
tryiesForReady.value > maxTryiesForReady
) {
return;
}
tryiesForReady.value = tryiesForReady.value + 1;
setTimeout(onObjectNewStatusCallback, 5000);
};
const onObjectNewStatusCallback = async function (): Promise<void> {
exportGeneration.value = await fetchExportGenerationStatus(
props.exportGenerationId,
);
if (isPending.value) {
checkForReady();
return Promise.resolve();
}
return Promise.resolve();
};
onMounted(() => {
onObjectNewStatusCallback();
});
</script>
<template>
<div id="waiting-screen">
<div
v-if="isPending && isFetching"
class="alert alert-danger text-center"
>
<div>
<p>
{{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }}
</p>
</div>
<div>
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
<span class="sr-only">Loading...</span>
</div>
</div>
<div v-if="isPending && !isFetching" class="alert alert-info">
<div>
<p>
{{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }}
</p>
</div>
</div>
<div v-if="isFailure" class="alert alert-danger text-center">
<div>
<p>
{{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }}
</p>
</div>
</div>
<div v-if="isReady" class="alert alert-success text-center">
<div>
<p>
{{ trans(EXPORT_GENERATION_EXPORT_READY) }}
</p>
<p v-if="storedObject !== null">
<document-action-buttons-group
:stored-object="storedObject"
:filename="filename"
></document-action-buttons-group>
</p>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
#waiting-screen {
> .alert {
min-height: 350px;
}
}
</style>

View File

@@ -0,0 +1,15 @@
import { createApp } from "vue";
import App from "./App.vue";
const el = document.getElementById("app");
if (null === el) {
console.error("div element app was not found");
throw new Error("div element app was not found");
}
const exportGenerationId = el?.dataset.exportGenerationId as string;
const title = el?.dataset.exportTitle as string;
const createdDate = el?.dataset.exportGenerationDate as string;
createApp(App, { exportGenerationId, title, createdDate }).mount(el);

Some files were not shown because too many files have changed in this diff Show More