Merge remote-tracking branch 'origin/master' into integrate_regroupment_entity

This commit is contained in:
Julien Fastré 2023-03-01 16:50:53 +01:00
commit 73fa585707
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
131 changed files with 3497 additions and 481 deletions

1
.gitignore vendored
View File

@ -5,6 +5,7 @@ composer.lock
docs/build/ docs/build/
node_modules/* node_modules/*
.php_cs.cache .php_cs.cache
.cache/*
###> symfony/framework-bundle ### ###> symfony/framework-bundle ###
/.env.local /.env.local

View File

@ -340,11 +340,6 @@ parameters:
count: 1 count: 1
path: src/Bundle/ChillPersonBundle/Form/Type/PersonPhoneType.php path: src/Bundle/ChillPersonBundle/Form/Type/PersonPhoneType.php
-
message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#"
count: 3
path: src/Bundle/ChillPersonBundle/Search/PersonSearch.php
- -
message: "#^Method Chill\\\\PersonBundle\\\\Search\\\\PersonSearch\\:\\:renderResult\\(\\) should return string but return statement is missing\\.$#" message: "#^Method Chill\\\\PersonBundle\\\\Search\\\\PersonSearch\\:\\:renderResult\\(\\) should return string but return statement is missing\\.$#"
count: 1 count: 1

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\ActivityBundle\Export\Filter\ACPFilters;
use Chill\ActivityBundle\Export\Declarations;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Form\Type\PickUserLocationType;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
class LocationFilter implements FilterInterface
{
private TranslatableStringHelper $translatableStringHelper;
public function __construct(TranslatableStringHelper $translatableStringHelper)
{
$this->translatableStringHelper = $translatableStringHelper;
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
$qb->andWhere(
$qb->expr()->in('activity.location', ':location')
);
$qb->setParameter('location', $data['accepted_location']);
}
public function applyOn(): string
{
return Declarations::ACTIVITY_ACP;
}
public function buildForm(FormBuilderInterface $builder)
{
$builder->add('accepted_location', PickUserLocationType::class, [
'multiple' => true,
'label' => 'pick location'
]);
}
public function describeAction($data, $format = 'string'): array
{
$locations = [];
foreach ($data['accepted_location'] as $location) {
$locations[] = $location->getName();
}
return ['Filtered activity by location: only %locations%', [
'%locations%' => implode(', ', $locations),
]];
}
public function getTitle(): string
{
return 'Filter activity by location';
}
}

View File

@ -22,6 +22,7 @@ use Chill\DocStoreBundle\Repository\DocumentCategoryRepository;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Templating\Entity\PersonRenderInterface; use Chill\PersonBundle\Templating\Entity\PersonRenderInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Bridge\Doctrine\Form\Type\EntityType;
@ -45,6 +46,8 @@ class ActivityContext implements
private PersonRenderInterface $personRender; private PersonRenderInterface $personRender;
private PersonRepository $personRepository;
private TranslatableStringHelperInterface $translatableStringHelper; private TranslatableStringHelperInterface $translatableStringHelper;
private TranslatorInterface $translator; private TranslatorInterface $translator;
@ -55,6 +58,7 @@ class ActivityContext implements
TranslatableStringHelperInterface $translatableStringHelper, TranslatableStringHelperInterface $translatableStringHelper,
EntityManagerInterface $em, EntityManagerInterface $em,
PersonRenderInterface $personRender, PersonRenderInterface $personRender,
PersonRepository $personRepository,
TranslatorInterface $translator, TranslatorInterface $translator,
BaseContextData $baseContextData BaseContextData $baseContextData
) { ) {
@ -63,6 +67,7 @@ class ActivityContext implements
$this->translatableStringHelper = $translatableStringHelper; $this->translatableStringHelper = $translatableStringHelper;
$this->em = $em; $this->em = $em;
$this->personRender = $personRender; $this->personRender = $personRender;
$this->personRepository = $personRepository;
$this->translator = $translator; $this->translator = $translator;
$this->baseContextData = $baseContextData; $this->baseContextData = $baseContextData;
} }
@ -206,6 +211,32 @@ class ActivityContext implements
return $options['mainPerson'] || $options['person1'] || $options['person2']; return $options['mainPerson'] || $options['person1'] || $options['person2'];
} }
public function contextGenerationDataNormalize(DocGeneratorTemplate $template, $entity, array $data): array
{
$normalized = [];
foreach (['mainPerson', 'person1', 'person2'] as $k) {
$normalized[$k] = null === $data[$k] ? null : $data[$k]->getId();
}
return $normalized;
}
public function contextGenerationDataDenormalize(DocGeneratorTemplate $template, $entity, array $data): array
{
$denormalized = [];
foreach (['mainPerson', 'person1', 'person2'] as $k) {
if (null !== ($id = ($data[$k] ?? null))) {
$denormalized[$k] = $this->personRepository->find($id);
} else {
$denormalized[$k] = null;
}
}
return $denormalized;
}
/** /**
* @param Activity $entity * @param Activity $entity
*/ */

View File

@ -146,6 +146,16 @@ class ListActivitiesByAccompanyingPeriodContext implements
return $this->accompanyingPeriodContext->hasPublicForm($template, $entity); return $this->accompanyingPeriodContext->hasPublicForm($template, $entity);
} }
public function contextGenerationDataNormalize(DocGeneratorTemplate $template, $entity, array $data): array
{
return $this->accompanyingPeriodContext->contextGenerationDataNormalize($template, $entity, $data);
}
public function contextGenerationDataDenormalize(DocGeneratorTemplate $template, $entity, array $data): array
{
return $this->accompanyingPeriodContext->contextGenerationDataDenormalize($template, $entity, $data);
}
public function storeGenerated(DocGeneratorTemplate $template, StoredObject $storedObject, object $entity, array $contextGenerationData): void public function storeGenerated(DocGeneratorTemplate $template, StoredObject $storedObject, object $entity, array $contextGenerationData): void
{ {
$this->accompanyingPeriodContext->storeGenerated($template, $storedObject, $entity, $contextGenerationData); $this->accompanyingPeriodContext->storeGenerated($template, $storedObject, $entity, $contextGenerationData);

View File

@ -79,6 +79,11 @@ services:
tags: tags:
- { name: chill.export_filter, alias: 'accompanyingcourse_activitytype_filter' } - { name: chill.export_filter, alias: 'accompanyingcourse_activitytype_filter' }
chill.activity.export.location_filter:
class: Chill\ActivityBundle\Export\Filter\ACPFilters\LocationFilter
tags:
- { name: chill.export_filter, alias: 'activity_location_filter' }
chill.activity.export.locationtype_filter: chill.activity.export.locationtype_filter:
class: Chill\ActivityBundle\Export\Filter\ACPFilters\LocationTypeFilter class: Chill\ActivityBundle\Export\Filter\ACPFilters\LocationTypeFilter
tags: tags:

View File

@ -77,7 +77,7 @@ Choose a type: Choisir un type
4 hours: 4 heures 4 hours: 4 heures
4 hours 30: 4 heures 30 4 hours 30: 4 heures 30
5 hours: 5 heures 5 hours: 5 heures
Concerned groups: Parties concernées Concerned groups: Parties concernées par l'échange
Persons in accompanying course: Usagers du parcours Persons in accompanying course: Usagers du parcours
Third persons: Tiers non-pro. Third persons: Tiers non-pro.
Others persons: Usagers Others persons: Usagers
@ -252,6 +252,8 @@ Activity reasons for those activities: Sujets de ces activités
Filter by activity type: Filtrer les activités par type Filter by activity type: Filtrer les activités par type
Filter activity by location: Filtrer les activités par localisation
'Filtered activity by location: only %locations%': "Filtré par localisation: uniquement %locations%"
Filter activity by locationtype: Filtrer les activités par type de localisation Filter activity by locationtype: Filtrer les activités par type de localisation
'Filtered activity by locationtype: only %types%': "Filtré par type de localisation: uniquement %types%" 'Filtered activity by locationtype: only %types%': "Filtré par type de localisation: uniquement %types%"
Accepted locationtype: Types de localisation Accepted locationtype: Types de localisation

View File

@ -53,19 +53,15 @@ class ByActivityTypeAggregator implements AggregatorInterface
public function getLabels($key, array $values, $data) public function getLabels($key, array $values, $data)
{ {
$this->asideActivityCategoryRepository->findBy(['id' => $values]);
return function ($value): string { return function ($value): string {
if ('_header' === $value) { if ('_header' === $value) {
return 'export.aggregator.Aside activity type'; return 'export.aggregator.Aside activity type';
} }
if (null === $value) { if (null === $value || null === $t = $this->asideActivityCategoryRepository->find($value)) {
return ''; return '';
} }
$t = $this->asideActivityCategoryRepository->find($value);
return $this->translatableStringHelper->localize($t->getTitle()); return $this->translatableStringHelper->localize($t->getTitle());
}; };
} }

View File

@ -0,0 +1,89 @@
<?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\AsideActivityBundle\Export\Aggregator;
use Chill\AsideActivityBundle\Export\Declarations;
use Chill\MainBundle\Export\AggregatorInterface;
use Chill\MainBundle\Repository\UserJobRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
use function in_array;
class ByUserJobAggregator implements AggregatorInterface
{
private TranslatableStringHelperInterface $translatableStringHelper;
private UserJobRepositoryInterface $userJobRepository;
public function __construct(UserJobRepositoryInterface $userJobRepository, TranslatableStringHelperInterface $translatableStringHelper)
{
$this->userJobRepository = $userJobRepository;
$this->translatableStringHelper = $translatableStringHelper;
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
if (!in_array('aside_user', $qb->getAllAliases(), true)) {
$qb->leftJoin('aside.agent', 'aside_user');
}
$qb
->addSelect('IDENTITY(aside_user.userJob) AS aside_activity_user_job_aggregator')
->addGroupBy('aside_activity_user_job_aggregator');
}
public function applyOn()
{
return Declarations::ASIDE_ACTIVITY_TYPE;
}
public function buildForm(FormBuilderInterface $builder)
{
// nothing to add in the form
}
public function getLabels($key, array $values, $data)
{
return function ($value): string {
if ('_header' === $value) {
return 'Users \'s job';
}
if (null === $value || '' === $value) {
return '';
}
$j = $this->userJobRepository->find($value);
return $this->translatableStringHelper->localize(
$j->getLabel()
);
};
}
public function getQueryKeys($data): array
{
return ['aside_activity_user_job_aggregator'];
}
public function getTitle()
{
return 'export.aggregator.Aggregate by user job';
}
}

View File

@ -0,0 +1,89 @@
<?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\AsideActivityBundle\Export\Aggregator;
use Chill\AsideActivityBundle\Export\Declarations;
use Chill\MainBundle\Export\AggregatorInterface;
use Chill\MainBundle\Repository\ScopeRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
use function in_array;
class ByUserScopeAggregator implements AggregatorInterface
{
private ScopeRepositoryInterface $scopeRepository;
private TranslatableStringHelperInterface $translatableStringHelper;
public function __construct(ScopeRepositoryInterface $scopeRepository, TranslatableStringHelperInterface $translatableStringHelper)
{
$this->scopeRepository = $scopeRepository;
$this->translatableStringHelper = $translatableStringHelper;
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
if (!in_array('aside_user', $qb->getAllAliases(), true)) {
$qb->leftJoin('aside.agent', 'aside_user');
}
$qb
->addSelect('IDENTITY(aside_user.mainScope) AS aside_activity_user_scope_aggregator')
->addGroupBy('aside_activity_user_scope_aggregator');
}
public function applyOn()
{
return Declarations::ASIDE_ACTIVITY_TYPE;
}
public function buildForm(FormBuilderInterface $builder)
{
// nothing to add in the form
}
public function getLabels($key, array $values, $data)
{
return function ($value): string {
if ('_header' === $value) {
return 'Users \'s scope';
}
if (null === $value || '' === $value) {
return '';
}
$s = $this->scopeRepository->find($value);
return $this->translatableStringHelper->localize(
$s->getName()
);
};
}
public function getQueryKeys($data): array
{
return ['aside_activity_user_scope_aggregator'];
}
public function getTitle()
{
return 'export.aggregator.Aggregate by user scope';
}
}

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\AsideActivityBundle\Export\Export;
use Chill\AsideActivityBundle\Export\Declarations;
use Chill\AsideActivityBundle\Repository\AsideActivityRepository;
use Chill\AsideActivityBundle\Security\AsideActivityVoter;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\FormatterInterface;
use Chill\MainBundle\Export\GroupedExportInterface;
use Doctrine\ORM\Query;
use LogicException;
use Symfony\Component\Form\FormBuilderInterface;
class AvgAsideActivityDuration implements ExportInterface, GroupedExportInterface
{
private AsideActivityRepository $repository;
public function __construct(
AsideActivityRepository $repository
) {
$this->repository = $repository;
}
public function buildForm(FormBuilderInterface $builder)
{
}
public function getAllowedFormattersTypes(): array
{
return [FormatterInterface::TYPE_TABULAR];
}
public function getDescription(): string
{
return 'export.Average aside activities duration';
}
public function getGroup(): string
{
return 'export.Exports of aside activities';
}
public function getLabels($key, array $values, $data)
{
if ('export_avg_aside_activity_duration' !== $key) {
throw new LogicException("the key {$key} is not used by this export");
}
return static fn ($value) => '_header' === $value ? 'Average duration aside activities' : $value;
}
public function getQueryKeys($data): array
{
return ['export_avg_aside_activity_duration'];
}
public function getResult($query, $data)
{
return $query->getQuery()->getResult(Query::HYDRATE_SCALAR);
}
public function getTitle(): string
{
return 'export.Average aside activities duration';
}
public function getType(): string
{
return Declarations::ASIDE_ACTIVITY_TYPE;
}
public function initiateQuery(array $requiredModifiers, array $acl, array $data = [])
{
$qb = $this->repository->createQueryBuilder('aside');
$qb
->select('AVG(aside.duration) as export_avg_aside_activity_duration')
->andWhere($qb->expr()->isNotNull('aside.duration'));
return $qb;
}
public function requiredRole(): string
{
return AsideActivityVoter::STATS;
}
public function supportsModifiers(): array
{
return [Declarations::ASIDE_ACTIVITY_TYPE];
}
}

View File

@ -11,12 +11,12 @@ declare(strict_types=1);
namespace Chill\AsideActivityBundle\Export\Export; namespace Chill\AsideActivityBundle\Export\Export;
use Chill\AsideActivityBundle\Export\Declarations;
use Chill\AsideActivityBundle\Repository\AsideActivityRepository; use Chill\AsideActivityBundle\Repository\AsideActivityRepository;
use Chill\AsideActivityBundle\Security\AsideActivityVoter; use Chill\AsideActivityBundle\Security\AsideActivityVoter;
use Chill\MainBundle\Export\ExportInterface; use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\FormatterInterface; use Chill\MainBundle\Export\FormatterInterface;
use Chill\MainBundle\Export\GroupedExportInterface; use Chill\MainBundle\Export\GroupedExportInterface;
use ChillAsideActivityBundle\Export\Declarations;
use Doctrine\ORM\Query; use Doctrine\ORM\Query;
use LogicException; use LogicException;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
@ -100,6 +100,8 @@ class CountAsideActivity implements ExportInterface, GroupedExportInterface
public function supportsModifiers(): array public function supportsModifiers(): array
{ {
return []; return [
Declarations::ASIDE_ACTIVITY_TYPE,
];
} }
} }

View File

@ -0,0 +1,236 @@
<?php
namespace Chill\AsideActivityBundle\Export\Export;
use Chill\AsideActivityBundle\Entity\AsideActivity;
use Chill\AsideActivityBundle\Export\Declarations;
use Chill\AsideActivityBundle\Form\AsideActivityCategoryType;
use Chill\AsideActivityBundle\Repository\AsideActivityCategoryRepository;
use Chill\AsideActivityBundle\Security\AsideActivityVoter;
use Chill\AsideActivityBundle\Templating\Entity\CategoryRender;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Export\FormatterInterface;
use Chill\MainBundle\Export\GroupedExportInterface;
use Chill\MainBundle\Export\Helper\DateTimeHelper;
use Chill\MainBundle\Export\Helper\UserHelper;
use Chill\MainBundle\Export\ListInterface;
use Chill\MainBundle\Repository\CenterRepositoryInterface;
use Chill\MainBundle\Repository\ScopeRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Closure;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
final class ListAsideActivity implements ListInterface, GroupedExportInterface
{
private EntityManagerInterface $em;
private UserHelper $userHelper;
private DateTimeHelper $dateTimeHelper;
private ScopeRepositoryInterface $scopeRepository;
private CenterRepositoryInterface $centerRepository;
private AsideActivityCategoryRepository $asideActivityCategoryRepository;
private CategoryRender $categoryRender;
private TranslatableStringHelperInterface $translatableStringHelper;
public function __construct(
EntityManagerInterface $em,
DateTimeHelper $dateTimeHelper,
UserHelper $userHelper,
ScopeRepositoryInterface $scopeRepository,
CenterRepositoryInterface $centerRepository,
AsideActivityCategoryRepository $asideActivityCategoryRepository,
CategoryRender $categoryRender,
TranslatableStringHelperInterface $translatableStringHelper
) {
$this->em = $em;
$this->dateTimeHelper = $dateTimeHelper;
$this->userHelper = $userHelper;
$this->scopeRepository = $scopeRepository;
$this->centerRepository = $centerRepository;
$this->asideActivityCategoryRepository = $asideActivityCategoryRepository;
$this->categoryRender = $categoryRender;
$this->translatableStringHelper = $translatableStringHelper;
}
public function buildForm(FormBuilderInterface $builder)
{
}
public function getAllowedFormattersTypes()
{
return [FormatterInterface::TYPE_LIST];
}
public function getDescription()
{
return 'export.aside_activity.List of aside activities';
}
public function getTitle()
{
return 'export.aside_activity.List of aside activities';
}
public function getGroup(): string
{
return 'export.Exports of aside activities';
}
public function getLabels($key, array $values, $data)
{
switch ($key) {
case 'id':
case 'note':
return function ($value) use ($key) {
if ('_header' === $value) {
return 'export.aside_activity.' . $key;
}
return $value ?? '';
};
case 'duration':
return function ($value) use ($key) {
if ('_header' === $value) {
return 'export.aside_activity.' . $key;
}
if (null === $value) {
return '';
}
if ($value instanceof \DateTimeInterface) {
return $value->format('H:i:s');
}
return $value;
};
case 'createdAt':
case 'updatedAt':
case 'date':
return $this->dateTimeHelper->getLabel('export.aside_activity.'.$key);
case 'agent_id':
case 'creator_id':
return $this->userHelper->getLabel($key, $values, 'export.aside_activity.' . $key);
case 'aside_activity_type':
return function ($value) {
if ('_header' === $value) {
return 'export.aside_activity.aside_activity_type';
}
if (null === $value || '' === $value || null === $c = $this->asideActivityCategoryRepository->find($value)) {
return '';
}
return $this->categoryRender->renderString($c, []);
};
case 'main_scope':
return function ($value) {
if ('_header' === $value) {
return 'export.aside_activity.main_scope';
}
if (null === $value || '' === $value || null === $c = $this->scopeRepository->find($value)) {
return '';
}
return $this->translatableStringHelper->localize($c->getName());
};
case 'main_center':
return function ($value) {
if ('_header' === $value) {
return 'export.aside_activity.main_center';
}
/** @var Center $c */
if (null === $value || '' === $value || null === $c = $this->centerRepository->find($value)) {
return '';
}
return $c->getName();
};
default:
throw new \LogicException('this key is not supported : ' . $key);
}
}
public function getQueryKeys($data)
{
return [
'id',
'createdAt',
'updatedAt',
'agent_id',
'creator_id',
'main_scope',
'main_center',
'aside_activity_type',
'date',
'duration',
'note'
];
}
/**
* @param QueryBuilder $query
* @param array $data
*/
public function getResult($query, $data): array
{
return $query->getQuery()->getResult(AbstractQuery::HYDRATE_ARRAY);
}
public function getType(): string
{
return Declarations::ASIDE_ACTIVITY_TYPE;
}
public function initiateQuery(array $requiredModifiers, array $acl, array $data = [])
{
$qb = $this->em->createQueryBuilder()
->from(AsideActivity::class, 'aside')
->leftJoin('aside.agent', 'agent')
;
$qb
->addSelect('aside.id AS id')
->addSelect('aside.createdAt AS createdAt')
->addSelect('aside.updatedAt AS updatedAt')
->addSelect('IDENTITY(aside.agent) AS agent_id')
->addSelect('IDENTITY(aside.createdBy) AS creator_id')
->addSelect('IDENTITY(agent.mainScope) AS main_scope')
->addSelect('IDENTITY(agent.mainCenter) AS main_center')
->addSelect('IDENTITY(aside.type) AS aside_activity_type')
->addSelect('aside.date')
->addSelect('aside.duration')
->addSelect('aside.note')
;
return $qb;
}
public function requiredRole(): string
{
return AsideActivityVoter::STATS;
}
public function supportsModifiers()
{
return [Declarations::ASIDE_ACTIVITY_TYPE];
}
}

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\AsideActivityBundle\Export\Export;
use Chill\AsideActivityBundle\Export\Declarations;
use Chill\AsideActivityBundle\Repository\AsideActivityRepository;
use Chill\AsideActivityBundle\Security\AsideActivityVoter;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\FormatterInterface;
use Chill\MainBundle\Export\GroupedExportInterface;
use Doctrine\ORM\Query;
use LogicException;
use Symfony\Component\Form\FormBuilderInterface;
class SumAsideActivityDuration implements ExportInterface, GroupedExportInterface
{
private AsideActivityRepository $repository;
public function __construct(
AsideActivityRepository $repository
) {
$this->repository = $repository;
}
public function buildForm(FormBuilderInterface $builder)
{
}
public function getAllowedFormattersTypes(): array
{
return [FormatterInterface::TYPE_TABULAR];
}
public function getDescription(): string
{
return 'export.Sum aside activities duration';
}
public function getGroup(): string
{
return 'export.Exports of aside activities';
}
public function getLabels($key, array $values, $data)
{
if ('export_sum_aside_activity_duration' !== $key) {
throw new LogicException("the key {$key} is not used by this export");
}
return static fn ($value) => '_header' === $value ? 'Sum duration aside activities' : $value;
}
public function getQueryKeys($data): array
{
return ['export_sum_aside_activity_duration'];
}
public function getResult($query, $data)
{
return $query->getQuery()->getResult(Query::HYDRATE_SCALAR);
}
public function getTitle(): string
{
return 'export.Sum aside activities duration';
}
public function getType(): string
{
return Declarations::ASIDE_ACTIVITY_TYPE;
}
public function initiateQuery(array $requiredModifiers, array $acl, array $data = [])
{
$qb = $this->repository
->createQueryBuilder('aside');
$qb->select('SUM(aside.duration) as export_sum_aside_activity_duration')
->andWhere($qb->expr()->isNotNull('aside.duration'));
return $qb;
}
public function requiredRole(): string
{
return AsideActivityVoter::STATS;
}
public function supportsModifiers(): array
{
return [Declarations::ASIDE_ACTIVITY_TYPE];
}
}

View File

@ -80,8 +80,8 @@ class ByActivityTypeFilter implements FilterInterface
public function describeAction($data, $format = 'string'): array public function describeAction($data, $format = 'string'): array
{ {
$types = array_map( $types = array_map(
fn (AsideActivityCategory $t): string => $this->translatableStringHelper->localize($t->getName()), fn (AsideActivityCategory $t): string => $this->translatableStringHelper->localize($t->getTitle()),
$this->asideActivityTypeRepository->findBy(['id' => $data['types']->toArray()]) $data['types']->toArray()
); );
return ['export.filter.Filtered by aside activity type: only %type%', [ return ['export.filter.Filtered by aside activity type: only %type%', [

View File

@ -46,25 +46,18 @@ class ByDateFilter implements FilterInterface
public function alterQuery(QueryBuilder $qb, $data) public function alterQuery(QueryBuilder $qb, $data)
{ {
$where = $qb->getDQLPart('where');
$clause = $qb->expr()->between( $clause = $qb->expr()->between(
'aside.date', 'aside.date',
':date_from', ':date_from',
':date_to' ':date_to'
); );
if ($where instanceof Andx) { $qb->andWhere($clause);
$where->add($clause);
} else {
$where = $qb->expr()->andX($clause);
}
$qb->add('where', $where);
$qb->setParameter( $qb->setParameter(
'date_from', 'date_from',
$this->rollingDateConverter->convert($data['date_from']) $this->rollingDateConverter->convert($data['date_from'])
); )->setParameter(
$qb->setParameter(
'date_to', 'date_to',
$this->rollingDateConverter->convert($data['date_to']) $this->rollingDateConverter->convert($data['date_to'])
); );

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\AsideActivityBundle\Export\Filter;
use Chill\AsideActivityBundle\Export\Declarations;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Templating\Entity\UserRender;
use Doctrine\ORM\Query\Expr\Andx;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
class ByUserFilter implements FilterInterface
{
private UserRender $userRender;
public function __construct(UserRender $userRender)
{
$this->userRender = $userRender;
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
$clause = $qb->expr()->in('aside.agent', ':users');
$qb
->andWhere($clause)
->setParameter('users', $data['accepted_users']);
}
public function applyOn(): string
{
return Declarations::ASIDE_ACTIVITY_TYPE;
}
public function buildForm(FormBuilderInterface $builder)
{
$builder->add('accepted_users', PickUserDynamicType::class, [
'multiple' => true,
'label' => 'Creators',
]);
}
public function describeAction($data, $format = 'string'): array
{
$users = [];
foreach ($data['accepted_users'] as $u) {
$users[] = $this->userRender->renderString($u, []);
}
return ['export.filter.Filtered aside activity by user: only %users%', [
'%users%' => implode(', ', $users),
]];
}
public function getTitle(): string
{
return 'export.filter.Filter aside activity by user';
}
}

View File

@ -0,0 +1,81 @@
<?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\AsideActivityBundle\Export\Filter;
use Chill\AsideActivityBundle\Entity\AsideActivity;
use Chill\AsideActivityBundle\Export\Declarations;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\FormBuilderInterface;
class ByUserJobFilter implements FilterInterface
{
private TranslatableStringHelperInterface $translatableStringHelper;
public function __construct(TranslatableStringHelperInterface $translatableStringHelper)
{
$this->translatableStringHelper = $translatableStringHelper;
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
$qb
->andWhere(
$qb->expr()->exists(
'SELECT 1 FROM ' . AsideActivity::class . ' aside_activity_user_job_filter_act
JOIN aside_activity_user_job_filter_act.agent aside_activity_user_job_filter_user WHERE aside_activity_user_job_filter_user.userJob IN (:aside_activity_user_job_filter_jobs) AND aside_activity_user_job_filter_act = aside'
)
)
->setParameter('aside_activity_user_job_filter_jobs', $data['jobs']);
}
public function applyOn()
{
return Declarations::ASIDE_ACTIVITY_TYPE;
}
public function buildForm(FormBuilderInterface $builder)
{
$builder->add('jobs', EntityType::class, [
'class' => UserJob::class,
'choice_label' => fn (UserJob $j) => $this->translatableStringHelper->localize($j->getLabel()),
'multiple' => true,
'expanded' => true,
]);
}
public function describeAction($data, $format = 'string')
{
return ['export.filter.Filtered aside activities by user jobs: only %jobs%', [
'%jobs%' => implode(
', ',
array_map(
fn (UserJob $job) => $this->translatableStringHelper->localize($job->getLabel()),
$data['jobs']->toArray()
)
),
]];
}
public function getTitle()
{
return 'export.filter.Filter by user jobs';
}
}

View File

@ -0,0 +1,88 @@
<?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\AsideActivityBundle\Export\Filter;
use Chill\AsideActivityBundle\Entity\AsideActivity;
use Chill\AsideActivityBundle\Export\Declarations;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Repository\ScopeRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\FormBuilderInterface;
class ByUserScopeFilter implements FilterInterface
{
private ScopeRepositoryInterface $scopeRepository;
private TranslatableStringHelperInterface $translatableStringHelper;
public function __construct(
ScopeRepositoryInterface $scopeRepository,
TranslatableStringHelperInterface $translatableStringHelper
) {
$this->scopeRepository = $scopeRepository;
$this->translatableStringHelper = $translatableStringHelper;
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
$qb
->andWhere(
$qb->expr()->exists(
'SELECT 1 FROM ' . AsideActivity::class . ' aside_activity_user_scope_filter_act
JOIN aside_activity_user_scope_filter_act.agent aside_activity_user_scope_filter_user WHERE aside_activity_user_scope_filter_user.mainScope IN (:aside_activity_user_scope_filter_scopes) AND aside_activity_user_scope_filter_act = aside '
)
)
->setParameter('aside_activity_user_scope_filter_scopes', $data['scopes']);
}
public function applyOn()
{
return Declarations::ASIDE_ACTIVITY_TYPE;
}
public function buildForm(FormBuilderInterface $builder)
{
$builder->add('scopes', EntityType::class, [
'class' => Scope::class,
'choices' => $this->scopeRepository->findAllActive(),
'choice_label' => fn (Scope $s) => $this->translatableStringHelper->localize($s->getName()),
'multiple' => true,
'expanded' => true,
]);
}
public function describeAction($data, $format = 'string')
{
return ['export.filter.Filtered aside activities by user scope: only %scopes%', [
'%scopes%' => implode(
', ',
array_map(
fn (Scope $s) => $this->translatableStringHelper->localize($s->getName()),
$data['scopes']->toArray()
)
),
]];
}
public function getTitle()
{
return 'export.filter.Filter by user scope';
}
}

View File

@ -20,33 +20,3 @@ services:
resource: "../Controller" resource: "../Controller"
autowire: true autowire: true
autoconfigure: true autoconfigure: true
## Exports
# indicators
Chill\AsideActivityBundle\Export\Export\CountAsideActivity:
autowire: true
autoconfigure: true
tags:
- { name: chill.export, alias: count_asideactivity }
# filters
Chill\AsideActivityBundle\Export\Filter\ByDateFilter:
autowire: true
autoconfigure: true
tags:
- { name: chill.export_filter, alias: asideactivity_bydate_filter }
Chill\AsideActivityBundle\Export\Filter\ByActivityTypeFilter:
autowire: true
autoconfigure: true
tags:
- { name: chill.export_filter, alias: asideactivity_activitytype_filter }
# aggregators
Chill\AsideActivityBundle\Export\Aggregator\ByActivityTypeAggregator:
autowire: true
autoconfigure: true
tags:
- { name: chill.export_aggregator, alias: asideactivity_activitytype_aggregator }

View File

@ -3,11 +3,23 @@ services:
autowire: true autowire: true
autoconfigure: true autoconfigure: true
Chill\AsideActivityBundle\Export\Export\ListAsideActivity:
tags:
- { name: chill.export, alias: 'list_aside_activity' }
## Indicators ## Indicators
Chill\AsideActivityBundle\Export\Export\CountAsideActivity: Chill\AsideActivityBundle\Export\Export\CountAsideActivity:
tags: tags:
- { name: chill.export, alias: 'count_aside_activity' } - { name: chill.export, alias: 'count_aside_activity' }
Chill\AsideActivityBundle\Export\Export\SumAsideActivityDuration:
tags:
- { name: chill.export, alias: 'sum_aside_activity_duration' }
Chill\AsideActivityBundle\Export\Export\AvgAsideActivityDuration:
tags:
- { name: chill.export, alias: 'avg_aside_activity_duration' }
## Filters ## Filters
chill.aside_activity.export.date_filter: chill.aside_activity.export.date_filter:
class: Chill\AsideActivityBundle\Export\Filter\ByDateFilter class: Chill\AsideActivityBundle\Export\Filter\ByDateFilter
@ -19,9 +31,34 @@ services:
tags: tags:
- { name: chill.export_filter, alias: 'aside_activity_type_filter' } - { name: chill.export_filter, alias: 'aside_activity_type_filter' }
chill.aside_activity.export.user_job_filter:
class: Chill\AsideActivityBundle\Export\Filter\ByUserJobFilter
tags:
- { name: chill.export_filter, alias: 'aside_activity_user_job_filter' }
chill.aside_activity.export.user_scope_filter:
class: Chill\AsideActivityBundle\Export\Filter\ByUserScopeFilter
tags:
- { name: chill.export_filter, alias: 'aside_activity_user_scope_filter' }
chill.aside_activity.export.user_filter:
class: Chill\AsideActivityBundle\Export\Filter\ByUserFilter
tags:
- { name: chill.export_filter, alias: 'aside_activity_user_filter' }
## Aggregators ## Aggregators
chill.aside_activity.export.type_aggregator: chill.aside_activity.export.type_aggregator:
class: Chill\AsideActivityBundle\Export\Aggregator\ByActivityTypeAggregator class: Chill\AsideActivityBundle\Export\Aggregator\ByActivityTypeAggregator
tags: tags:
- { name: chill.export_aggregator, alias: activity_type_aggregator } - { name: chill.export_aggregator, alias: activity_type_aggregator }
chill.aside_activity.export.user_job_aggregator:
class: Chill\AsideActivityBundle\Export\Aggregator\ByUserJobAggregator
tags:
- { name: chill.export_aggregator, alias: aside_activity_user_job_aggregator }
chill.aside_activity.export.user_scope_aggregator:
class: Chill\AsideActivityBundle\Export\Aggregator\ByUserScopeAggregator
tags:
- { name: chill.export_aggregator, alias: aside_activity_user_scope_aggregator }

View File

@ -29,16 +29,16 @@ location: Lieu
# Crud # Crud
crud: crud:
aside_activity: aside_activity:
title_view: Détail de l'activité annexe title_view: Détail de l'activité annexe
title_new: Nouvelle activité annexe title_new: Nouvelle activité annexe
title_edit: Édition d'une activité annexe title_edit: Édition d'une activité annexe
title_delete: Supprimer une activité annexe title_delete: Supprimer une activité annexe
button_delete: Supprimer button_delete: Supprimer
confirm_message_delete: Êtes-vous sûr de vouloir supprimer cette activité annexe? confirm_message_delete: Êtes-vous sûr de vouloir supprimer cette activité annexe?
aside_activity_category: aside_activity_category:
title_new: Nouvelle catégorie d'activité annexe title_new: Nouvelle catégorie d'activité annexe
title_edit: Édition d'une catégorie de type d'activité title_edit: Édition d'une catégorie de type d'activité
#forms #forms
Create a new aside activity type: Nouvelle categorie d'activité annexe Create a new aside activity type: Nouvelle categorie d'activité annexe
@ -169,19 +169,43 @@ Aside activity configuration: Configuration des activités annexes
# exports # exports
export: export:
Exports of aside activities: Exports des activités annexes aside_activity:
Count aside activities: Nombre d'activités annexes List of aside activities: Liste des activités annexes
Count aside activities by various parameters.: Compte le nombre d'activités annexes selon divers critères createdAt: Création
filter: updatedAt: Dernière mise à jour
Filter by aside activity date: Filtrer les activités annexes par date agent_id: Utilisateur
Filter by aside activity type: Filtrer les activités annexes par type d'activité creator_id: Créateur
'Filtered by aside activity type: only %type%': "Filtré par type d'activité annexe: uniquement %type%" main_scope: Service principal de l'utilisateur
This date should be after the date given in "Implied in an aside activity after this date" field: Cette date devrait être postérieure à la date donnée dans le champ "activités annexes après cette date" main_center: Centre principal de l'utilisteur
Aside activities after this date: Actvitités annexes après cette date aside_activity_type: Catégorie d'activité annexe
Aside activities before this date: Actvitités annexes avant cette date date: Date
aggregator: duration: Durée
Group by aside activity type: Grouper les activités annexes par type d'activité note: Note
Aside activity type: Type d'activité annexe
Exports of aside activities: Exports des activités annexes
Count aside activities: Nombre d'activités annexes
Count aside activities by various parameters.: Compte le nombre d'activités annexes selon divers critères
Average aside activities duration: Durée moyenne des activités annexes
Sum aside activities duration: Durée des activités annexes
filter:
Filter by aside activity date: Filtrer les activités annexes par date
Filter by aside activity type: Filtrer les activités annexes par type d'activité
'Filtered by aside activity type: only %type%': "Filtré par type d'activité annexe: uniquement %type%"
Filtered by aside activities between %dateFrom% and %dateTo%: Filtré par date d'activité annexe, entre %dateFrom% et %dateTo%
This date should be after the date given in "Implied in an aside activity after this date" field: Cette date devrait être postérieure à la date donnée dans le champ "activités annexes après cette date"
Aside activities after this date: Actvitités annexes après cette date
Aside activities before this date: Actvitités annexes avant cette date
'Filtered aside activity by user: only %users%': "Filtré par utilisateur: uniquement %users%"
Filter aside activity by user: Filtrer par utilisateur
'Filtered aside activities by user jobs: only %jobs%': "Filtré par métier des utilisateurs: uniquement %jobs%"
Filter by user jobs: Filtrer les activités annexes par métier des utilisateurs
'Filtered aside activities by user scope: only %scopes%': "Filtré par service des utilisateur: uniquement %scopes%"
Filter by user scope: Filtrer les activités annexes par service d'utilisateur
aggregator:
Group by aside activity type: Grouper les activités annexes par type d'activité
Aside activity type: Type d'activité annexe
Aggregate by user job: Grouper les activités annexes par métier des utilisateurs
Aggregate by user scope: Grouper les activités annexes par service des utilisateurs
# ROLES # ROLES
CHILL_ASIDE_ACTIVITY_STATS: Statistiques pour les activités annexes CHILL_ASIDE_ACTIVITY_STATS: Statistiques pour les activités annexes

View File

@ -12,12 +12,17 @@ declare(strict_types=1);
namespace Chill\BudgetBundle\Entity; namespace Chill\BudgetBundle\Entity;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
/** /**
* Type of charge. * Type of charge.
* *
* @ORM\Table(name="chill_budget.charge_type") * @ORM\Table(name="chill_budget.charge_type",
* uniqueConstraints={@ORM\UniqueConstraint(name="charge_kind_unique_type_idx", fields={"kind"})}
* )
* @ORM\Entity * @ORM\Entity
* @UniqueEntity(fields={"kind"})
*/ */
class ChargeKind class ChargeKind
{ {
@ -35,6 +40,8 @@ class ChargeKind
/** /**
* @ORM\Column(type="string", length=255, options={"default": ""}, nullable=false) * @ORM\Column(type="string", length=255, options={"default": ""}, nullable=false)
* @Assert\Regex(pattern="/^[a-z0-9\-_]{1,}$/", message="budget.admin.form.kind.only_alphanumeric")
* @Assert\Length(min=3)
*/ */
private string $kind = ''; private string $kind = '';

View File

@ -12,12 +12,17 @@ declare(strict_types=1);
namespace Chill\BudgetBundle\Entity; namespace Chill\BudgetBundle\Entity;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
/** /**
* Type of resource. * Type of resource.
* *
* @ORM\Table(name="chill_budget.resource_type") * @ORM\Table(name="chill_budget.resource_type", uniqueConstraints={
* @ORM\UniqueConstraint(name="resource_kind_unique_type_idx", fields={"kind"})
* })
* @ORM\Entity * @ORM\Entity
* @UniqueEntity(fields={"kind"})
*/ */
class ResourceKind class ResourceKind
{ {
@ -35,6 +40,8 @@ class ResourceKind
/** /**
* @ORM\Column(type="string", length=255, nullable=false, options={"default": ""}) * @ORM\Column(type="string", length=255, nullable=false, options={"default": ""})
* @Assert\Regex(pattern="/^[a-z0-9\-_]{1,}$/", message="budget.admin.form.kind.only_alphanumeric")
* @Assert\Length(min=3)
*/ */
private string $kind = ''; private string $kind = '';

View File

@ -16,6 +16,7 @@ use Chill\MainBundle\Form\Type\TranslatableStringFormType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\NumberType; use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@ -25,7 +26,11 @@ class ChargeKindType extends AbstractType
{ {
$builder $builder
->add('name', TranslatableStringFormType::class, [ ->add('name', TranslatableStringFormType::class, [
'label' => 'Nom', 'label' => 'Title',
])
->add('kind', TextType::class, [
'label' => 'budget.admin.form.Charge_kind_key',
'help' => 'budget.admin.form.This kind must contains only alphabeticals characters, and dashes. This string is in use during document generation. Changes may have side effect on document'
]) ])
->add('ordering', NumberType::class) ->add('ordering', NumberType::class)
->add('isActive', CheckboxType::class, [ ->add('isActive', CheckboxType::class, [

View File

@ -16,6 +16,7 @@ use Chill\MainBundle\Form\Type\TranslatableStringFormType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\NumberType; use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@ -25,7 +26,11 @@ class ResourceKindType extends AbstractType
{ {
$builder $builder
->add('name', TranslatableStringFormType::class, [ ->add('name', TranslatableStringFormType::class, [
'label' => 'Nom', 'label' => 'Title',
])
->add('kind', TextType::class, [
'label' => 'budget.admin.form.Resource_kind_key',
'help' => 'budget.admin.form.This kind must contains only alphabeticals characters, and dashes. This string is in use during document generation. Changes may have side effect on document'
]) ])
->add('ordering', NumberType::class) ->add('ordering', NumberType::class)
->add('isActive', CheckboxType::class, [ ->add('isActive', CheckboxType::class, [

View File

@ -14,9 +14,8 @@ namespace Chill\BudgetBundle\Repository;
use Chill\BudgetBundle\Entity\ChargeKind; use Chill\BudgetBundle\Entity\ChargeKind;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository;
class ChargeKindRepository implements ObjectRepository final class ChargeKindRepository implements ChargeKindRepositoryInterface
{ {
private EntityRepository $repository; private EntityRepository $repository;
@ -50,7 +49,8 @@ class ChargeKindRepository implements ObjectRepository
->where($qb->expr()->eq('c.isActive', 'true')) ->where($qb->expr()->eq('c.isActive', 'true'))
->orderBy('c.ordering', 'ASC') ->orderBy('c.ordering', 'ASC')
->getQuery() ->getQuery()
->getResult(); ->getResult()
;
} }
/** /**
@ -77,6 +77,11 @@ class ChargeKindRepository implements ObjectRepository
return $this->repository->findOneBy($criteria); return $this->repository->findOneBy($criteria);
} }
public function findOneByKind(string $kind): ?ChargeKind
{
return $this->repository->findOneBy(['kind' => $kind]);
}
public function getClassName(): string public function getClassName(): string
{ {
return ChargeKind::class; return ChargeKind::class;

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\BudgetBundle\Repository;
use Chill\BudgetBundle\Entity\ChargeKind;
use Doctrine\Persistence\ObjectRepository;
interface ChargeKindRepositoryInterface extends ObjectRepository
{
public function find($id): ?ChargeKind;
/**
* @return ChargeType[]
*/
public function findAll(): array;
/**
* @return ChargeType[]
*/
public function findAllActive(): array;
public function findOneByKind(string $kind): ?ChargeKind;
/**
* @return ChargeType[]
*/
public function findAllByType(string $type): array;
/**
* @param mixed|null $limit
* @param mixed|null $offset
*
* @return ChargeType[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
public function findOneBy(array $criteria): ?ChargeKind;
public function getClassName(): string;
}

View File

@ -14,9 +14,8 @@ namespace Chill\BudgetBundle\Repository;
use Chill\BudgetBundle\Entity\ResourceKind; use Chill\BudgetBundle\Entity\ResourceKind;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository;
class ResourceKindRepository implements ObjectRepository final class ResourceKindRepository implements ResourceKindRepositoryInterface
{ {
private EntityRepository $repository; private EntityRepository $repository;
@ -50,7 +49,8 @@ class ResourceKindRepository implements ObjectRepository
->where($qb->expr()->eq('r.isActive', 'true')) ->where($qb->expr()->eq('r.isActive', 'true'))
->orderBy('r.ordering', 'ASC') ->orderBy('r.ordering', 'ASC')
->getQuery() ->getQuery()
->getResult(); ->getResult()
;
} }
/** /**
@ -77,6 +77,11 @@ class ResourceKindRepository implements ObjectRepository
return $this->repository->findOneBy($criteria); return $this->repository->findOneBy($criteria);
} }
public function findOneByKind(string $kind): ?ResourceKind
{
return $this->repository->findOneBy(['kind' => $kind]);
}
public function getClassName(): string public function getClassName(): string
{ {
return ResourceKind::class; return ResourceKind::class;

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\BudgetBundle\Repository;
use Chill\BudgetBundle\Entity\ResourceKind;
use Doctrine\Persistence\ObjectRepository;
interface ResourceKindRepositoryInterface extends ObjectRepository
{
public function find($id): ?ResourceKind;
/**
* @return ResourceType[]
*/
public function findAll(): array;
/**
* @return ResourceType[]
*/
public function findAllActive(): array;
/**
* @return ResourceType[]
*/
public function findAllByType(string $type): array;
/**
* @param mixed|null $limit
* @param mixed|null $offset
*
* @return ResourceType[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
public function findOneBy(array $criteria): ?ResourceKind;
public function findOneByKind(string $kind): ?ResourceKind;
public function getClassName(): string;
}

View File

@ -34,7 +34,7 @@ class ResourceRepository extends EntityRepository
//->andWhere('c.startDate < :date') //->andWhere('c.startDate < :date')
// TODO: there is a misconception here, the end date must be lower or null. startDate are never null // TODO: there is a misconception here, the end date must be lower or null. startDate are never null
//->andWhere('c.startDate < :date OR c.startDate IS NULL'); //->andWhere('c.startDate < :date OR c.startDate IS NULL');
; ;
if (null !== $sort) { if (null !== $sort) {
$qb->orderBy($sort); $qb->orderBy($sort);

View File

@ -19,7 +19,10 @@
{% for entity in entities %} {% for entity in entities %}
<tr> <tr>
<td>{{ entity.ordering }}</td> <td>{{ entity.ordering }}</td>
<td>{{ entity|chill_entity_render_box }}</td> <td>
{{ entity|chill_entity_render_box }}<br/>
<strong>{{ 'budget.admin.form.Charge_kind_key'|trans }}&nbsp;:</strong> <code>{{ entity.kind }}</code>
</td>
<td style="text-align:center;"> <td style="text-align:center;">
{%- if entity.isActive -%} {%- if entity.isActive -%}
<i class="fa fa-check-square-o"></i> <i class="fa fa-check-square-o"></i>
@ -39,6 +42,8 @@
</tbody> </tbody>
</table> </table>
{{ chill_pagination(paginator) }}
<ul class="record_actions sticky-form-buttons"> <ul class="record_actions sticky-form-buttons">
<li> <li>
<a href="{{ path('chill_crud_charge_kind_new') }}" class="btn btn-create"> <a href="{{ path('chill_crud_charge_kind_new') }}" class="btn btn-create">

View File

@ -19,7 +19,10 @@
{% for entity in entities %} {% for entity in entities %}
<tr> <tr>
<td>{{ entity.ordering }}</td> <td>{{ entity.ordering }}</td>
<td>{{ entity|chill_entity_render_box }}</td> <td>
{{ entity|chill_entity_render_box }}<br/>
<strong>{{ 'budget.admin.form.Resource_kind_key'|trans }}&nbsp;:</strong> <code>{{ entity.kind }}</code>
</td>
<td style="text-align:center;"> <td style="text-align:center;">
{%- if entity.isActive -%} {%- if entity.isActive -%}
<i class="fa fa-check-square-o"></i> <i class="fa fa-check-square-o"></i>
@ -39,6 +42,8 @@
</tbody> </tbody>
</table> </table>
{{ chill_pagination(paginator) }}
<ul class="record_actions sticky-form-buttons"> <ul class="record_actions sticky-form-buttons">
<li> <li>
<a href="{{ path('chill_crud_resource_kind_new') }}" class="btn btn-create"> <a href="{{ path('chill_crud_resource_kind_new') }}" class="btn btn-create">

View File

@ -14,7 +14,9 @@ namespace Chill\BudgetBundle\Service\Summary;
use Chill\BudgetBundle\Entity\ChargeKind; use Chill\BudgetBundle\Entity\ChargeKind;
use Chill\BudgetBundle\Entity\ResourceKind; use Chill\BudgetBundle\Entity\ResourceKind;
use Chill\BudgetBundle\Repository\ChargeKindRepository; use Chill\BudgetBundle\Repository\ChargeKindRepository;
use Chill\BudgetBundle\Repository\ChargeKindRepositoryInterface;
use Chill\BudgetBundle\Repository\ResourceKindRepository; use Chill\BudgetBundle\Repository\ResourceKindRepository;
use Chill\BudgetBundle\Repository\ResourceKindRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\Household\Household; use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
@ -27,7 +29,7 @@ use function count;
/** /**
* Helps to find a summary of the budget: the sum of resources and charges. * Helps to find a summary of the budget: the sum of resources and charges.
*/ */
class SummaryBudget implements SummaryBudgetInterface final class SummaryBudget implements SummaryBudgetInterface
{ {
private const QUERY_CHARGE_BY_HOUSEHOLD = 'select SUM(amount) AS sum, string_agg(comment, \'|\') AS comment, charge_id AS kind_id FROM chill_budget.charge WHERE (person_id IN (_ids_) OR household_id = ?) AND NOW() BETWEEN startdate AND COALESCE(enddate, \'infinity\'::timestamp) GROUP BY charge_id'; private const QUERY_CHARGE_BY_HOUSEHOLD = 'select SUM(amount) AS sum, string_agg(comment, \'|\') AS comment, charge_id AS kind_id FROM chill_budget.charge WHERE (person_id IN (_ids_) OR household_id = ?) AND NOW() BETWEEN startdate AND COALESCE(enddate, \'infinity\'::timestamp) GROUP BY charge_id';
@ -37,23 +39,19 @@ class SummaryBudget implements SummaryBudgetInterface
private const QUERY_RESOURCE_BY_PERSON = 'select SUM(amount) AS sum, string_agg(comment, \'|\') AS comment, resource_id AS kind_id FROM chill_budget.resource WHERE person_id = ? AND NOW() BETWEEN startdate AND COALESCE(enddate, \'infinity\'::timestamp) GROUP BY resource_id'; private const QUERY_RESOURCE_BY_PERSON = 'select SUM(amount) AS sum, string_agg(comment, \'|\') AS comment, resource_id AS kind_id FROM chill_budget.resource WHERE person_id = ? AND NOW() BETWEEN startdate AND COALESCE(enddate, \'infinity\'::timestamp) GROUP BY resource_id';
private ChargeKindRepository $chargeKindRepository; private ChargeKindRepositoryInterface $chargeKindRepository;
private array $chargeLabels;
private EntityManagerInterface $em; private EntityManagerInterface $em;
private ResourceKindRepository $resourceKindRepository; private ResourceKindRepositoryInterface $resourceKindRepository;
private array $resourcesLabels;
private TranslatableStringHelperInterface $translatableStringHelper; private TranslatableStringHelperInterface $translatableStringHelper;
public function __construct( public function __construct(
EntityManagerInterface $em, EntityManagerInterface $em,
TranslatableStringHelperInterface $translatableStringHelper, TranslatableStringHelperInterface $translatableStringHelper,
ResourceKindRepository $resourceKindRepository, ResourceKindRepositoryInterface $resourceKindRepository,
ChargeKindRepository $chargeKindRepository ChargeKindRepositoryInterface $chargeKindRepository
) { ) {
$this->em = $em; $this->em = $em;
$this->translatableStringHelper = $translatableStringHelper; $this->translatableStringHelper = $translatableStringHelper;
@ -129,19 +127,19 @@ class SummaryBudget implements SummaryBudgetInterface
private function getEmptyChargeArray(): array private function getEmptyChargeArray(): array
{ {
$keys = array_map(static fn (ChargeKind $kind) => $kind->getId(), $this->chargeKindRepository->findAll()); $keys = array_map(static fn (ChargeKind $kind) => $kind->getKind(), $this->chargeKindRepository->findAll());
return array_combine($keys, array_map(function ($id) { return array_combine($keys, array_map(function ($kind) {
return ['sum' => 0.0, 'label' => $this->translatableStringHelper->localize($this->chargeKindRepository->find($id)->getName()), 'comment' => '']; return ['sum' => 0.0, 'label' => $this->translatableStringHelper->localize($this->chargeKindRepository->findOneByKind($kind)->getName()), 'comment' => ''];
}, $keys)); }, $keys));
} }
private function getEmptyResourceArray(): array private function getEmptyResourceArray(): array
{ {
$keys = array_map(static fn (ResourceKind $kind) => $kind->getId(), $this->resourceKindRepository->findAll()); $keys = array_map(static fn (ResourceKind $kind) => $kind->getKind(), $this->resourceKindRepository->findAll());
return array_combine($keys, array_map(function ($id) { return array_combine($keys, array_map(function ($kind) {
return ['sum' => 0.0, 'label' => $this->translatableStringHelper->localize($this->resourceKindRepository->find($id)->getName()), 'comment' => '']; return ['sum' => 0.0, 'label' => $this->translatableStringHelper->localize($this->resourceKindRepository->findOneByKind($kind)->getName()), 'comment' => ''];
}, $keys)); }, $keys));
} }
@ -155,7 +153,7 @@ class SummaryBudget implements SummaryBudgetInterface
$chargeKind = $this->chargeKindRepository->find($row['kind_id']); $chargeKind = $this->chargeKindRepository->find($row['kind_id']);
if (null === $chargeKind) { if (null === $chargeKind) {
throw new RuntimeException('charge kind not found'); throw new RuntimeException('charge kind not found: ' . $row['kind_id']);
} }
$result[$chargeKind->getKind()] = [ $result[$chargeKind->getKind()] = [
'sum' => (float) $row['sum'], 'sum' => (float) $row['sum'],
@ -171,7 +169,7 @@ class SummaryBudget implements SummaryBudgetInterface
$resourceKind = $this->resourceKindRepository->find($row['kind_id']); $resourceKind = $this->resourceKindRepository->find($row['kind_id']);
if (null === $resourceKind) { if (null === $resourceKind) {
throw new RuntimeException('charge kind not found'); throw new RuntimeException('charge kind not found: ' . $row['kind_id']);
} }
$result[$resourceKind->getKind()] = [ $result[$resourceKind->getKind()] = [

View File

@ -0,0 +1,158 @@
<?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\BudgetBundle\Tests\Service\Summary;
use Chill\BudgetBundle\Entity\ChargeKind;
use Chill\BudgetBundle\Entity\ResourceKind;
use Chill\BudgetBundle\Repository\ChargeKindRepositoryInterface;
use Chill\BudgetBundle\Repository\ResourceKindRepositoryInterface;
use Chill\BudgetBundle\Service\Summary\SummaryBudget;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
/**
* @internal
*
* @coversNothing
*/
final class SummaryBudgetTest extends TestCase
{
use ProphecyTrait;
public function testGenerateSummaryForPerson(): void
{
$queryCharges = $this->prophesize(AbstractQuery::class);
$queryCharges->getResult()->willReturn([
[
'sum' => 250.0,
'comment' => '',
'kind_id' => 1, // kind: rental
],
]);
$queryCharges->setParameters(Argument::type('array'))
->will(function ($args, $query) {
return $query;
})
;
$queryResources = $this->prophesize(AbstractQuery::class);
$queryResources->getResult()->willReturn([
[
'sum' => 1500.0,
'comment' => '',
'kind_id' => 2, // kind: 'salary',
],
]);
$queryResources->setParameters(Argument::type('array'))
->will(function ($args, $query) {
return $query;
})
;
$em = $this->prophesize(EntityManagerInterface::class);
$em->createNativeQuery(Argument::type('string'), Argument::type(Query\ResultSetMapping::class))
->will(function ($args) use ($queryResources, $queryCharges) {
if (false !== strpos($args[0], 'chill_budget.resource')) {
return $queryResources->reveal();
}
if (false !== strpos($args[0], 'chill_budget.charge')) {
return $queryCharges->reveal();
}
throw new \RuntimeException('this query does not have a stub counterpart: '.$args[0]);
})
;
$chargeRepository = $this->prophesize(ChargeKindRepositoryInterface::class);
$chargeRepository->findAll()->willReturn([
$rental = (new ChargeKind())->setKind('rental')->setName(['fr' => 'Rental']),
$other = (new ChargeKind())->setKind('other')->setName(['fr' => 'Other']),
]);
$chargeRepository->find(1)->willReturn($rental);
$chargeRepository->findOneByKind('rental')->willReturn($rental);
$chargeRepository->findOneByKind('other')->willReturn($other);
$resourceRepository = $this->prophesize(ResourceKindRepositoryInterface::class);
$resourceRepository->findAll()->willReturn([
$salary = (new ResourceKind())->setKind('salary')->setName(['fr' => 'Salary']),
$misc = (new ResourceKind())->setKind('misc')->setName(['fr' => 'Misc']),
]);
$resourceRepository->find(2)->willReturn($salary);
$resourceRepository->findOneByKind('salary')->willReturn($salary);
$resourceRepository->findOneByKind('misc')->willReturn($misc);
$translatableStringHelper = $this->prophesize(TranslatableStringHelperInterface::class);
$translatableStringHelper->localize(Argument::type('array'))->will(function ($arg) {
return $arg[0]['fr'];
});
$person = new Person();
$personReflection = new \ReflectionClass($person);
$personIdReflection = $personReflection->getProperty('id');
$personIdReflection->setAccessible(true);
$personIdReflection->setValue($person, 1);
$household = new Household();
$householdReflection = new \ReflectionClass($household);
$householdId = $householdReflection->getProperty('id');
$householdId->setAccessible(true);
$householdId->setValue($household, 1);
$householdMember = (new HouseholdMember())->setPerson($person)
->setStartDate(new \DateTimeImmutable('1 month ago'))
;
$household->addMember($householdMember);
$summaryBudget = new SummaryBudget(
$em->reveal(),
$translatableStringHelper->reveal(),
$resourceRepository->reveal(),
$chargeRepository->reveal()
);
$summary = $summaryBudget->getSummaryForPerson($person);
$summaryForHousehold = $summaryBudget->getSummaryForHousehold($household);
// we check the structure for the summary. The structure is the same for household
// and persons
$expected = [
'charges' => [
'rental' => ['sum' => 250.0, 'comment' => '', 'label' => 'Rental'],
'other' => ['sum' => 0.0, 'comment' => '', 'label' => 'Other'],
],
'resources' => [
'salary' => ['sum' => 1500.0, 'comment' => '', 'label' => 'Salary'],
'misc' => ['sum' => 0.0, 'comment' => '', 'label' => 'Misc'],
],
];
foreach ([$summaryForHousehold, $summary] as $summary) {
$this->assertIsArray($summary);
$this->assertEqualsCanonicalizing(['charges', 'resources'], array_keys($summary));
$this->assertEqualsCanonicalizing(['rental', 'other'], array_keys($summary['charges']));
$this->assertEqualsCanonicalizing(['salary', 'misc'], array_keys($summary['resources']));
foreach ($expected as $resCha => $contains) {
foreach ($contains as $kind => $row) {
$this->assertEqualsCanonicalizing($row, $summary[$resCha][$kind]);
}
}
}
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\Budget;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20230209161546 extends AbstractMigration
{
public function getDescription(): string
{
return 'Budget: add unique constraint on kind for charge_kind and resource_kind';
}
public function up(Schema $schema): void
{
$this->addSql("UPDATE chill_budget.resource_type SET kind=md5(random()::text) WHERE kind = ''");
$this->addSql("UPDATE chill_budget.charge_type SET kind=md5(random()::text) WHERE kind = ''");
$this->addSql('CREATE UNIQUE INDEX resource_kind_unique_type_idx ON chill_budget.resource_type (kind);');
$this->addSql('CREATE UNIQUE INDEX charge_kind_unique_type_idx ON chill_budget.charge_type (kind);');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX resource_kind_unique_type_idx');
$this->addSql('DROP INDEX charge_kind_unique_type_idx');
}
}

View File

@ -77,6 +77,13 @@ The balance: Différence entre ressources et charges
Valid since %startDate% until %endDate%: Valide depuis le %startDate% jusqu'au %endDate% Valid since %startDate% until %endDate%: Valide depuis le %startDate% jusqu'au %endDate%
Valid since %startDate%: Valide depuis le %startDate% Valid since %startDate%: Valide depuis le %startDate%
budget:
admin:
form:
Charge_kind_key: Clé d'identification
Resource_kind_key: Clé d'identification
This kind must contains only alphabeticals characters, and dashes. This string is in use during document generation. Changes may have side effect on document: Cette clé sert à identifier le type de charge ou de revenu lors de la génération de document. Seuls les caractères alpha-numériques sont autorisés. Modifier cette clé peut avoir un effet lors de la génération de nouveaux documents.
# ROLES # ROLES
Budget elements: Budget Budget elements: Budget
CHILL_BUDGET_ELEMENT_CREATE: Créer une ressource/charge CHILL_BUDGET_ELEMENT_CREATE: Créer une ressource/charge

View File

@ -1,2 +1,8 @@
The amount cannot be empty: Le montant ne peut pas être vide ou égal à zéro The amount cannot be empty: Le montant ne peut pas être vide ou égal à zéro
The budget element's end date must be after the start date: La date de fin doit être après la date de début The budget element's end date must be after the start date: La date de fin doit être après la date de début
budget:
admin:
form:
kind:
only_alphanumeric

View File

@ -61,7 +61,7 @@ class LoadCalendarRange extends Fixture implements FixtureGroupInterface, Ordere
->setEmail('centreA@test.chill.social') ->setEmail('centreA@test.chill.social')
->setLocationType($type = new LocationType()) ->setLocationType($type = new LocationType())
->setPhonenumber1(PhoneNumberUtil::getInstance()->parse('+3287653812')); ->setPhonenumber1(PhoneNumberUtil::getInstance()->parse('+3287653812'));
$type->setTitle('Service'); $type->setTitle(['fr' => 'Service']);
$address->setStreet('Rue des Épaules')->setStreetNumber('14') $address->setStreet('Rue des Épaules')->setStreetNumber('14')
->setPostcode($postCode = new PostalCode()); ->setPostcode($postCode = new PostalCode());
$postCode->setCode('4145')->setName('Houte-Si-Plout')->setCountry( $postCode->setCode('4145')->setName('Houte-Si-Plout')->setCountry(

View File

@ -12,6 +12,8 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\DataFixtures\ORM; namespace Chill\CalendarBundle\DataFixtures\ORM;
use Chill\CalendarBundle\Entity\Invite; use Chill\CalendarBundle\Entity\Invite;
use Chill\MainBundle\DataFixtures\ORM\LoadUsers;
use Chill\MainBundle\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface; use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
@ -33,14 +35,21 @@ class LoadInvite extends Fixture implements FixtureGroupInterface
public function load(ObjectManager $manager): void public function load(ObjectManager $manager): void
{ {
$arr = [ $arr = [
['name' => ['fr' => 'Rendez-vous décliné']], [
['name' => ['fr' => 'Rendez-vous accepté']], 'name' => ['fr' => 'Rendez-vous décliné'],
'status' => Invite::DECLINED,
],
[
'name' => ['fr' => 'Rendez-vous accepté'],
'status' => Invite::ACCEPTED,
],
]; ];
foreach ($arr as $a) { foreach ($arr as $a) {
echo 'Creating calendar invite : ' . $a['name']['fr'] . "\n"; echo 'Creating calendar invite : ' . $a['name']['fr'] . "\n";
$invite = (new Invite()) $invite = (new Invite())
->setStatus($a['name']); ->setStatus($a['status'])
->setUser($this->getRandomUser());
$manager->persist($invite); $manager->persist($invite);
$reference = 'Invite_' . $a['name']['fr']; $reference = 'Invite_' . $a['name']['fr'];
$this->addReference($reference, $invite); $this->addReference($reference, $invite);
@ -49,4 +58,11 @@ class LoadInvite extends Fixture implements FixtureGroupInterface
$manager->flush(); $manager->flush();
} }
private function getRandomUser(): User
{
$userRef = array_rand(LoadUsers::$refs);
return $this->getReference($userRef);
}
} }

View File

@ -17,30 +17,20 @@
<td class="eval"> <td class="eval">
<ul class="eval_title"> <ul class="eval_title">
<li> <li>
{{ mm.mimeIcon(d.storedObject.type) }} <div class="row">
{{ d.storedObject.title }} <div class="col text-start">
{% if d.dateTimeVersion < d.calendar.dateTimeVersion %} {{ d.storedObject.title }}
<span class="badge bg-danger">{{ 'chill_calendar.Document outdated'|trans }}</span> {% if d.dateTimeVersion < d.calendar.dateTimeVersion %}
{% endif %} <span class="badge bg-danger">{{ 'chill_calendar.Document outdated'|trans }}</span>
{% endif %}
<ul class="record_actions small inline"> </div>
{% if chill_document_is_editable(d.storedObject) and is_granted('CHILL_CALENDAR_DOC_EDIT', d) %} <div class="col-md-auto text-center">
<li> {{ mm.mimeIcon(d.storedObject.type) }}
<a href="{{ chill_path_add_return_path('chill_calendar_calendardoc_delete', {'id': d.id})}}" class="btn btn-delete"></a> </div>
</li> <div class="col col-lg-4 text-end">
<li> {{ d.storedObject|chill_document_button_group(d.storedObject.title, is_granted('CHILL_CALENDAR_DOC_EDIT', d), {'small': true}) }}
{{ d.storedObject|chill_document_edit_button }} </div>
</li> </div>
{% endif %}
{% if is_granted('CHILL_CALENDAR_DOC_EDIT', d) %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendardoc_edit', {'id': d.id})}}" class="btn btn-edit"></a>
</li>
{% endif %}
<li>
{{ m.download_button(d.storedObject, d.storedObject.title) }}
</li>
</ul>
</li> </li>
</ul> </ul>
</td> </td>

View File

@ -7,7 +7,7 @@
<div id="mainUser"></div> {# <=== vue component: mainUser #} <div id="mainUser"></div> {# <=== vue component: mainUser #}
<h2 class="chill-red">{{ 'Concerned groups'|trans }}</h2> <h2 class="chill-red">{{ 'Concerned groups calendar'|trans }}</h2>
{%- if form.persons is defined -%} {%- if form.persons is defined -%}
{{ form_widget(form.persons) }} {{ form_widget(form.persons) }}

View File

@ -10,13 +10,13 @@
{% block js %} {% block js %}
{{ parent() }} {{ parent() }}
{{ encore_entry_script_tags('mod_answer') }} {{ encore_entry_script_tags('mod_answer') }}
{{ encore_entry_script_tags('mod_async_upload') }} {{ encore_entry_script_tags('mod_document_action_buttons_group') }}
{% endblock %} {% endblock %}
{% block css %} {% block css %}
{{ parent() }} {{ parent() }}
{{ encore_entry_link_tags('mod_answer') }} {{ encore_entry_link_tags('mod_answer') }}
{{ encore_entry_link_tags('mod_async_upload') }} {{ encore_entry_link_tags('mod_document_action_buttons_group') }}
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@ -7,7 +7,7 @@
<div id="mainUser"></div> {# <=== vue component: mainUser #} <div id="mainUser"></div> {# <=== vue component: mainUser #}
<h2 class="chill-red">{{ 'Concerned groups'|trans }}</h2> <h2 class="chill-red">{{ 'Concerned groups calendar'|trans }}</h2>
{%- if form.mainUser is defined -%} {%- if form.mainUser is defined -%}
{{ form_row(form.mainUser) }} {{ form_row(form.mainUser) }}

View File

@ -14,7 +14,7 @@
<dd>{{ entity.mainUser }}</dd> <dd>{{ entity.mainUser }}</dd>
</dl> </dl>
<h2 class="chill-red">{{ 'Concerned groups'|trans }}</h2> <h2 class="chill-red">{{ 'Concerned groups calendar'|trans }}</h2>
{% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with {'context': 'calendar_' ~ context, 'render': 'bloc' } %} {% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with {'context': 'calendar_' ~ context, 'render': 'bloc' } %}

View File

@ -18,8 +18,10 @@ use Chill\DocGeneratorBundle\Service\Context\BaseContextData;
use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Templating\Entity\PersonRender; use Chill\PersonBundle\Templating\Entity\PersonRender;
use Chill\ThirdPartyBundle\Entity\ThirdParty; use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Chill\ThirdPartyBundle\Repository\ThirdPartyRepository;
use Chill\ThirdPartyBundle\Templating\Entity\ThirdPartyRender; use Chill\ThirdPartyBundle\Templating\Entity\ThirdPartyRender;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Bridge\Doctrine\Form\Type\EntityType;
@ -39,6 +41,10 @@ final class CalendarContext implements CalendarContextInterface
private PersonRender $personRender; private PersonRender $personRender;
private PersonRepository $personRepository;
private ThirdPartyRepository $thirdPartyRepository;
private ThirdPartyRender $thirdPartyRender; private ThirdPartyRender $thirdPartyRender;
private TranslatableStringHelperInterface $translatableStringHelper; private TranslatableStringHelperInterface $translatableStringHelper;
@ -48,14 +54,18 @@ final class CalendarContext implements CalendarContextInterface
EntityManagerInterface $entityManager, EntityManagerInterface $entityManager,
NormalizerInterface $normalizer, NormalizerInterface $normalizer,
PersonRender $personRender, PersonRender $personRender,
PersonRepository $personRepository,
ThirdPartyRender $thirdPartyRender, ThirdPartyRender $thirdPartyRender,
ThirdPartyRepository $thirdPartyRepository,
TranslatableStringHelperInterface $translatableStringHelper TranslatableStringHelperInterface $translatableStringHelper
) { ) {
$this->baseContextData = $baseContextData; $this->baseContextData = $baseContextData;
$this->entityManager = $entityManager; $this->entityManager = $entityManager;
$this->normalizer = $normalizer; $this->normalizer = $normalizer;
$this->personRender = $personRender; $this->personRender = $personRender;
$this->personRepository = $personRepository;
$this->thirdPartyRender = $thirdPartyRender; $this->thirdPartyRender = $thirdPartyRender;
$this->thirdPartyRepository = $thirdPartyRepository;
$this->translatableStringHelper = $translatableStringHelper; $this->translatableStringHelper = $translatableStringHelper;
} }
@ -226,8 +236,44 @@ final class CalendarContext implements CalendarContextInterface
return true; return true;
} }
public function contextGenerationDataNormalize(DocGeneratorTemplate $template, $entity, array $data): array
{
$normalized = [];
$normalized['title'] = $data['title'] ?? '';
foreach (['mainPerson', 'thirdParty'] as $k) {
if (isset($data[$k])) {
$normalized[$k] = $data[$k]->getId();
}
}
return $normalized;
}
public function contextGenerationDataDenormalize(DocGeneratorTemplate $template, $entity, array $data): array
{
$denormalized = [];
$denormalized['title'] = $data['title'];
if (null !== ($data['mainPerson'] ?? null)) {
if (null === $person = $this->personRepository->find($data['mainPerson'])) {
throw new \RuntimeException('person not found');
}
$denormalized['mainPerson'] = $person;
}
if (null !== ($data['thirdParty'] ?? null)) {
if (null === $thirdParty = $this->thirdPartyRepository->find($data['thirdParty'])) {
throw new \RuntimeException('third party not found');
}
$denormalized['thirdParty'] = $thirdParty;
}
return $denormalized;
}
/** /**
* @param array{mainPerson?: Person, thirdParty?: ThirdParty, title: string} $contextGenerationData * param array{mainPerson?: Person, thirdParty?: ThirdParty, title: string} $contextGenerationData
*/ */
public function storeGenerated(DocGeneratorTemplate $template, StoredObject $storedObject, object $entity, array $contextGenerationData): void public function storeGenerated(DocGeneratorTemplate $template, StoredObject $storedObject, object $entity, array $contextGenerationData): void
{ {

View File

@ -56,6 +56,10 @@ interface CalendarContextInterface extends DocGeneratorContextWithPublicFormInte
*/ */
public function hasPublicForm(DocGeneratorTemplate $template, $entity): bool; public function hasPublicForm(DocGeneratorTemplate $template, $entity): bool;
public function contextGenerationDataNormalize(DocGeneratorTemplate $template, $entity, array $data): array;
public function contextGenerationDataDenormalize(DocGeneratorTemplate $template, $entity, array $data): array;
/** /**
* @param Calendar $entity * @param Calendar $entity
*/ */

View File

@ -4,7 +4,7 @@ My calendar list: Mes rendez-vous
There is no calendar items.: Il n'y a pas de rendez-vous There is no calendar items.: Il n'y a pas de rendez-vous
Remove calendar item: Supprimer le rendez-vous Remove calendar item: Supprimer le rendez-vous
Are you sure you want to remove the calendar item?: Êtes-vous sûr de vouloir supprimer le rendez-vous? Are you sure you want to remove the calendar item?: Êtes-vous sûr de vouloir supprimer le rendez-vous?
Concerned groups: Parties concernées Concerned groups calendar: Parties concernées
Calendar data: Données du rendez-vous Calendar data: Données du rendez-vous
Update calendar: Modifier le rendez-vous Update calendar: Modifier le rendez-vous
main user concerned: Utilisateur concerné main user concerned: Utilisateur concerné

View File

@ -23,6 +23,9 @@ interface DocGeneratorContextWithPublicFormInterface extends DocGeneratorContext
*/ */
public function buildPublicForm(FormBuilderInterface $builder, DocGeneratorTemplate $template, $entity): void; public function buildPublicForm(FormBuilderInterface $builder, DocGeneratorTemplate $template, $entity): void;
/**
* Fill the form with initial data
*/
public function getFormData(DocGeneratorTemplate $template, $entity): array; public function getFormData(DocGeneratorTemplate $template, $entity): array;
/** /**
@ -31,4 +34,14 @@ interface DocGeneratorContextWithPublicFormInterface extends DocGeneratorContext
* @param mixed $entity * @param mixed $entity
*/ */
public function hasPublicForm(DocGeneratorTemplate $template, $entity): bool; public function hasPublicForm(DocGeneratorTemplate $template, $entity): bool;
/**
* Transform the data from the form into serializable data, storable into messenger's message
*/
public function contextGenerationDataNormalize(DocGeneratorTemplate $template, $entity, array $data): array;
/**
* Reverse the data from the messenger's message into data usable for doc's generation
*/
public function contextGenerationDataDenormalize(DocGeneratorTemplate $template, $entity, array $data): array;
} }

View File

@ -16,67 +16,57 @@ use Chill\DocGeneratorBundle\Context\DocGeneratorContextWithPublicFormInterface;
use Chill\DocGeneratorBundle\Context\Exception\ContextNotFoundException; use Chill\DocGeneratorBundle\Context\Exception\ContextNotFoundException;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate; use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocGeneratorBundle\GeneratorDriver\DriverInterface; use Chill\DocGeneratorBundle\GeneratorDriver\DriverInterface;
use Chill\DocGeneratorBundle\GeneratorDriver\Exception\TemplateException;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepository; use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepository;
use Chill\DocGeneratorBundle\Service\Generator\GeneratorInterface;
use Chill\DocGeneratorBundle\Service\Messenger\RequestGenerationMessage;
use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Serializer\Model\Collection; use Chill\MainBundle\Serializer\Model\Collection;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
// TODO à mettre dans services // TODO à mettre dans services
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
use function strlen; use function strlen;
final class DocGeneratorTemplateController extends AbstractController final class DocGeneratorTemplateController extends AbstractController
{ {
private HttpClientInterface $client;
private ContextManager $contextManager; private ContextManager $contextManager;
private DocGeneratorTemplateRepository $docGeneratorTemplateRepository; private DocGeneratorTemplateRepository $docGeneratorTemplateRepository;
private DriverInterface $driver;
private EntityManagerInterface $entityManager; private EntityManagerInterface $entityManager;
private LoggerInterface $logger; private GeneratorInterface $generator;
private MessageBusInterface $messageBus;
private PaginatorFactory $paginatorFactory; private PaginatorFactory $paginatorFactory;
private StoredObjectManagerInterface $storedObjectManager;
public function __construct( public function __construct(
ContextManager $contextManager, ContextManager $contextManager,
DocGeneratorTemplateRepository $docGeneratorTemplateRepository, DocGeneratorTemplateRepository $docGeneratorTemplateRepository,
DriverInterface $driver, GeneratorInterface $generator,
LoggerInterface $logger, MessageBusInterface $messageBus,
PaginatorFactory $paginatorFactory, PaginatorFactory $paginatorFactory,
HttpClientInterface $client,
StoredObjectManagerInterface $storedObjectManager,
EntityManagerInterface $entityManager EntityManagerInterface $entityManager
) { ) {
$this->contextManager = $contextManager; $this->contextManager = $contextManager;
$this->docGeneratorTemplateRepository = $docGeneratorTemplateRepository; $this->docGeneratorTemplateRepository = $docGeneratorTemplateRepository;
$this->driver = $driver; $this->generator = $generator;
$this->logger = $logger; $this->messageBus = $messageBus;
$this->paginatorFactory = $paginatorFactory; $this->paginatorFactory = $paginatorFactory;
$this->client = $client;
$this->storedObjectManager = $storedObjectManager;
$this->entityManager = $entityManager; $this->entityManager = $entityManager;
} }
@ -94,7 +84,6 @@ final class DocGeneratorTemplateController extends AbstractController
): Response { ): Response {
return $this->generateDocFromTemplate( return $this->generateDocFromTemplate(
$template, $template,
$entityClassName,
$entityId, $entityId,
$request, $request,
true true
@ -115,7 +104,6 @@ final class DocGeneratorTemplateController extends AbstractController
): Response { ): Response {
return $this->generateDocFromTemplate( return $this->generateDocFromTemplate(
$template, $template,
$entityClassName,
$entityId, $entityId,
$request, $request,
false false
@ -185,7 +173,6 @@ final class DocGeneratorTemplateController extends AbstractController
private function generateDocFromTemplate( private function generateDocFromTemplate(
DocGeneratorTemplate $template, DocGeneratorTemplate $template,
string $entityClassName,
int $entityId, int $entityId,
Request $request, Request $request,
bool $isTest bool $isTest
@ -206,7 +193,7 @@ final class DocGeneratorTemplateController extends AbstractController
if (null === $entity) { if (null === $entity) {
throw new NotFoundHttpException( throw new NotFoundHttpException(
sprintf('Entity with classname %s and id %s is not found', $entityClassName, $entityId) sprintf('Entity with classname %s and id %s is not found', $context->getEntityClass(), $entityId)
); );
} }
@ -259,98 +246,68 @@ final class DocGeneratorTemplateController extends AbstractController
} }
} }
$document = $template->getFile(); // transform context generation data
$contextGenerationDataSanitized =
if ($isTest && ($contextGenerationData['test_file'] instanceof File)) { $context instanceof DocGeneratorContextWithPublicFormInterface ?
$dataDecrypted = file_get_contents($contextGenerationData['test_file']->getPathname()); $context->contextGenerationDataNormalize($template, $entity, $contextGenerationData)
} else { : [];
try {
$dataDecrypted = $this->storedObjectManager->read($document);
} catch (Throwable $exception) {
throw $exception;
}
}
// if is test, render the data or generate the doc
if ($isTest && isset($form) && $form['show_data']->getData()) { if ($isTest && isset($form) && $form['show_data']->getData()) {
// very ugly hack... return $this->render('@ChillDocGenerator/Generator/debug_value.html.twig', [
dd($context->getData($template, $entity, $contextGenerationData)); 'datas' => json_encode($context->getData($template, $entity, $contextGenerationData), JSON_PRETTY_PRINT)
} ]);
} elseif ($isTest) {
try { $generated = $this->generator->generateDocFromTemplate(
$generatedResource = $this $template,
->driver $entityId,
->generateFromString( $contextGenerationDataSanitized,
$dataDecrypted, null,
$template->getFile()->getType(), true,
$context->getData($template, $entity, $contextGenerationData), isset($form) ? $form['test_file']->getData() : null
$template->getFile()->getFilename()
);
} catch (TemplateException $e) {
return new Response(
implode("\n", $e->getErrors()),
400,
[
'Content-Type' => 'text/plain',
]
); );
}
if ($isTest) {
return new Response( return new Response(
$generatedResource, $generated,
Response::HTTP_OK, Response::HTTP_OK,
[ [
'Content-Transfer-Encoding', 'binary', 'Content-Transfer-Encoding', 'binary',
'Content-Type' => 'application/vnd.oasis.opendocument.text', 'Content-Type' => 'application/vnd.oasis.opendocument.text',
'Content-Disposition' => 'attachment; filename="generated.odt"', 'Content-Disposition' => 'attachment; filename="generated.odt"',
'Content-Length' => strlen($generatedResource), 'Content-Length' => strlen($generated),
], ],
); );
} }
/** @var StoredObject $storedObject */ // this is not a test
$storedObject = (new ObjectNormalizer()) // we prepare the object to store the document
->denormalize( $storedObject = (new StoredObject())
[ ->setStatus(StoredObject::STATUS_PENDING)
'type' => $template->getFile()->getType(), ;
'filename' => sprintf('%s_odt', uniqid('doc_', true)),
],
StoredObject::class
);
try {
$this->storedObjectManager->write($storedObject, $generatedResource);
} catch (Throwable $exception) {
throw $exception;
}
$this->entityManager->persist($storedObject); $this->entityManager->persist($storedObject);
try { // we store the generated document
$context $context
->storeGenerated( ->storeGenerated(
$template, $template,
$storedObject, $storedObject,
$entity, $entity,
$contextGenerationData $contextGenerationData
); );
} catch (Exception $e) {
$this
->logger
->error(
'Unable to store the associated document to entity',
[
'entityClassName' => $entityClassName,
'entityId' => $entityId,
'contextKey' => $context->getName(),
]
);
throw $e;
}
$this->entityManager->flush(); $this->entityManager->flush();
$this->messageBus->dispatch(
new RequestGenerationMessage(
$this->getUser(),
$template,
$entityId,
$storedObject,
$contextGenerationDataSanitized,
)
);
return $this return $this
->redirectToRoute( ->redirectToRoute(
'chill_wopi_file_edit', 'chill_wopi_file_edit',

View File

@ -53,6 +53,7 @@ final class RelatorioDriver implements DriverInterface
$response = $this->client->request('POST', $this->url, [ $response = $this->client->request('POST', $this->url, [
'headers' => $form->getPreparedHeaders()->toArray(), 'headers' => $form->getPreparedHeaders()->toArray(),
'body' => $form->bodyToIterable(), 'body' => $form->bodyToIterable(),
'timeout' => '300',
]); ]);
return $response->getContent(); return $response->getContent();

View File

@ -0,0 +1,16 @@
{{ creator.label }},
{{ 'docgen.failure_email.The generation of the document {template_name} failed'|trans({'{template_name}': template.name|localize_translatable_string}) }}
{{ 'docgen.failure_email.Forward this email to your administrator for solving'|trans }}
{{ 'docgen.failure_email.References'|trans }}:
{% if errors|length > 0 %}
{{ 'docgen.failure_email.The following errors were encoutered'|trans }}:
{% for error in errors %}
- {{ error }}
{% endfor %}
{% endif %}
- template_id: {{ template.id }}
- stored_object_destination_id: {{ stored_object_id }}

View File

@ -0,0 +1,8 @@
<html>
<head>
<title>{{ 'Doc generator debug'|trans }}</title>
</head>
<body>
<pre>{{ datas }}</pre>
</body>
</html>

View File

@ -0,0 +1,143 @@
<?php
namespace Chill\DocGeneratorBundle\Service\Generator;
use Chill\DocGeneratorBundle\Context\ContextManagerInterface;
use Chill\DocGeneratorBundle\Context\DocGeneratorContextWithPublicFormInterface;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocGeneratorBundle\GeneratorDriver\DriverInterface;
use Chill\DocGeneratorBundle\GeneratorDriver\Exception\TemplateException;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\File\File;
class Generator implements GeneratorInterface
{
private ContextManagerInterface $contextManager;
private DriverInterface $driver;
private EntityManagerInterface $entityManager;
private LoggerInterface $logger;
private StoredObjectManagerInterface $storedObjectManager;
private const LOG_PREFIX = '[docgen generator] ';
public function __construct(
ContextManagerInterface $contextManager,
DriverInterface $driver,
EntityManagerInterface $entityManager,
LoggerInterface $logger,
StoredObjectManagerInterface $storedObjectManager
) {
$this->contextManager = $contextManager;
$this->driver = $driver;
$this->entityManager = $entityManager;
$this->logger = $logger;
$this->storedObjectManager = $storedObjectManager;
}
/**
* @template T of File|null
* @template B of bool
* @param B $isTest
* @param (B is true ? T : null) $testFile
* @psalm-return (B is true ? string : null)
* @throws \Symfony\Component\Serializer\Exception\ExceptionInterface|\Throwable
*/
public function generateDocFromTemplate(
DocGeneratorTemplate $template,
int $entityId,
array $contextGenerationDataNormalized,
?StoredObject $destinationStoredObject = null,
bool $isTest = false,
?File $testFile = null
): ?string {
if ($destinationStoredObject instanceof StoredObject && StoredObject::STATUS_PENDING !== $destinationStoredObject->getStatus()) {
$this->logger->info(self::LOG_PREFIX.'Aborting generation of an already generated document');
throw new ObjectReadyException();
}
$this->logger->info(self::LOG_PREFIX.'Starting generation of a document', [
'entity_id' => $entityId,
'destination_stored_object' => $destinationStoredObject === null ? null : $destinationStoredObject->getId()
]);
$context = $this->contextManager->getContextByDocGeneratorTemplate($template);
$entity = $this
->entityManager
->find($context->getEntityClass(), $entityId)
;
if (null === $entity) {
throw new RelatedEntityNotFoundException($template->getEntity(), $entityId);
}
$contextGenerationDataNormalized = array_merge(
$contextGenerationDataNormalized,
$context instanceof DocGeneratorContextWithPublicFormInterface ?
$context->contextGenerationDataDenormalize($template, $entity, $contextGenerationDataNormalized)
: []
);
$data = $context->getData($template, $entity, $contextGenerationDataNormalized);
$destinationStoredObjectId = $destinationStoredObject instanceof StoredObject ? $destinationStoredObject->getId() : null;
$this->entityManager->clear();
gc_collect_cycles();
if (null !== $destinationStoredObjectId) {
$destinationStoredObject = $this->entityManager->find(StoredObject::class, $destinationStoredObjectId);
}
if ($isTest && ($testFile instanceof File)) {
$templateDecrypted = file_get_contents($testFile->getPathname());
} else {
$templateDecrypted = $this->storedObjectManager->read($template->getFile());
}
try {
$generatedResource = $this
->driver
->generateFromString(
$templateDecrypted,
$template->getFile()->getType(),
$data,
$template->getFile()->getFilename()
);
} catch (TemplateException $e) {
throw new GeneratorException($e->getErrors(), $e);
}
if ($isTest) {
$this->logger->info(self::LOG_PREFIX.'Finished generation of a document', [
'is_test' => true,
'entity_id' => $entityId,
'destination_stored_object' => $destinationStoredObject === null ? null : $destinationStoredObject->getId()
]);
return $generatedResource;
}
/** @var StoredObject $storedObject */
$destinationStoredObject
->setType($template->getFile()->getType())
->setFilename(sprintf('%s_odt', uniqid('doc_', true)))
->setStatus(StoredObject::STATUS_READY)
;
$this->storedObjectManager->write($destinationStoredObject, $generatedResource);
$this->entityManager->flush();
$this->logger->info(self::LOG_PREFIX.'Finished generation of a document', [
'entity_id' => $entityId,
'destination_stored_object' => $destinationStoredObject->getId(),
]);
return null;
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace Chill\DocGeneratorBundle\Service\Generator;
class GeneratorException extends \RuntimeException
{
/**
* @var list<string>
*/
private array $errors;
public function __construct(array $errors = [], \Throwable $previous = null)
{
$this->errors = $errors;
parent::__construct("Could not generate the document", 15252,
$previous);
}
/**
* @return array
*/
public function getErrors(): array
{
return $this->errors;
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Chill\DocGeneratorBundle\Service\Generator;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocStoreBundle\Entity\StoredObject;
use Symfony\Component\HttpFoundation\File\File;
interface GeneratorInterface
{
/**
* @template T of File|null
* @template B of bool
* @param B $isTest
* @param (B is true ? T : null) $testFile
* @psalm-return (B is true ? string : null)
* @throws \Symfony\Component\Serializer\Exception\ExceptionInterface|\Throwable
*/
public function generateDocFromTemplate(
DocGeneratorTemplate $template,
int $entityId,
array $contextGenerationDataNormalized,
?StoredObject $destinationStoredObject = null,
bool $isTest = false,
?File $testFile = null
): ?string;
}

View File

@ -0,0 +1,11 @@
<?php
namespace Chill\DocGeneratorBundle\Service\Generator;
class ObjectReadyException extends \RuntimeException
{
public function __construct()
{
parent::__construct("object is already ready", 6698856);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace Chill\DocGeneratorBundle\Service\Generator;
class RelatedEntityNotFoundException extends \RuntimeException
{
public function __construct(string $relatedEntityClass, int $relatedEntityId, Throwable $previous = null)
{
parent::__construct(
sprintf("Related entity not found: %s, %s", $relatedEntityClass, $relatedEntityId),
99876652,
$previous);
}
}

View File

@ -0,0 +1,156 @@
<?php
namespace Chill\DocGeneratorBundle\Service\Messenger;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepository;
use Chill\DocGeneratorBundle\Service\Generator\GeneratorException;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\StoredObjectRepository;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
use Symfony\Contracts\Translation\TranslatorInterface;
final class OnGenerationFails implements EventSubscriberInterface
{
private DocGeneratorTemplateRepository $docGeneratorTemplateRepository;
private EntityManagerInterface $entityManager;
private LoggerInterface $logger;
private MailerInterface $mailer;
private StoredObjectRepository $storedObjectRepository;
private TranslatorInterface $translator;
private UserRepositoryInterface $userRepository;
const LOG_PREFIX = '[docgen failed] ';
/**
* @param DocGeneratorTemplateRepository $docGeneratorTemplateRepository
* @param EntityManagerInterface $entityManager
* @param LoggerInterface $logger
* @param MailerInterface $mailer
* @param StoredObjectRepository $storedObjectRepository
* @param TranslatorInterface $translator
* @param UserRepositoryInterface $userRepository
*/
public function __construct(
DocGeneratorTemplateRepository $docGeneratorTemplateRepository,
EntityManagerInterface $entityManager,
LoggerInterface $logger,
MailerInterface $mailer,
StoredObjectRepository $storedObjectRepository,
TranslatorInterface $translator,
UserRepositoryInterface $userRepository
) {
$this->docGeneratorTemplateRepository = $docGeneratorTemplateRepository;
$this->entityManager = $entityManager;
$this->logger = $logger;
$this->mailer = $mailer;
$this->storedObjectRepository = $storedObjectRepository;
$this->translator = $translator;
$this->userRepository = $userRepository;
}
public static function getSubscribedEvents()
{
return [
WorkerMessageFailedEvent::class => 'onMessageFailed'
];
}
public function onMessageFailed(WorkerMessageFailedEvent $event): void
{
if ($event->willRetry()) {
return;
}
if (!$event->getEnvelope()->getMessage() instanceof RequestGenerationMessage) {
return;
}
/** @var \Chill\DocGeneratorBundle\Service\Messenger\RequestGenerationMessage $message */
$message = $event->getEnvelope()->getMessage();
$this->logger->error(self::LOG_PREFIX.'Docgen failed', [
'stored_object_id' => $message->getDestinationStoredObjectId(),
'entity_id' => $message->getEntityId(),
'template_id' => $message->getTemplateId(),
'creator_id' => $message->getCreatorId(),
'throwable_class' => get_class($event->getThrowable()),
]);
$this->markObjectAsFailed($message);
$this->warnCreator($message, $event);
}
private function markObjectAsFailed(RequestGenerationMessage $message): void
{
$object = $this->storedObjectRepository->find($message->getDestinationStoredObjectId());
if (null === $object) {
$this->logger->error(self::LOG_PREFIX.'Stored object not found', ['stored_object_id', $message->getDestinationStoredObjectId()]);
}
$object->setStatus(StoredObject::STATUS_FAILURE);
$this->entityManager->flush();
}
private function warnCreator(RequestGenerationMessage $message, WorkerMessageFailedEvent $event): void
{
if (null === $creatorId = $message->getCreatorId()) {
$this->logger->info(self::LOG_PREFIX.'creator id is null');
return;
}
if (null === $creator = $this->userRepository->find($creatorId)) {
$this->logger->error(self::LOG_PREFIX.'Creator not found with given id', ['creator_id', $creatorId]);
return;
}
if (null === $creator->getEmail() || '' === $creator->getEmail()) {
$this->logger->info(self::LOG_PREFIX.'Creator does not have any email', ['user' => $creator->getUsernameCanonical()]);
return;
}
// if the exception is not a GeneratorException, we try the previous one...
$throwable = $event->getThrowable();
if (!$throwable instanceof GeneratorException) {
$throwable = $throwable->getPrevious();
}
if ($throwable instanceof GeneratorException) {
$errors = $throwable->getErrors();
} else {
$errors = [$throwable->getTraceAsString()];
}
if (null === $template = $this->docGeneratorTemplateRepository->find($message->getTemplateId())) {
$this->logger->info(self::LOG_PREFIX.'Template not found', ['template_id' => $message->getTemplateId()]);
return;
}
$email = (new TemplatedEmail())
->to($creator->getEmail())
->subject($this->translator->trans('docgen.failure_email.The generation of a document failed'))
->textTemplate('@ChillDocGenerator/Email/on_generation_failed_email.txt.twig')
->context([
'errors' => $errors,
'template' => $template,
'creator' => $creator,
'stored_object_id' => $message->getDestinationStoredObjectId(),
]);
$this->mailer->send($email);
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace Chill\DocGeneratorBundle\Service\Messenger;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepository;
use Chill\DocGeneratorBundle\Service\Generator\Generator;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\StoredObjectRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
/**
* Handle the request of document generation
*/
class RequestGenerationHandler implements MessageHandlerInterface
{
private StoredObjectRepository $storedObjectRepository;
private DocGeneratorTemplateRepository $docGeneratorTemplateRepository;
private EntityManagerInterface $entityManager;
private Generator $generator;
public const AUTHORIZED_TRIALS = 5;
public function __construct(
DocGeneratorTemplateRepository $docGeneratorTemplateRepository,
EntityManagerInterface $entityManager,
Generator $generator,
StoredObjectRepository $storedObjectRepository
) {
$this->docGeneratorTemplateRepository = $docGeneratorTemplateRepository;
$this->entityManager = $entityManager;
$this->generator = $generator;
$this->storedObjectRepository = $storedObjectRepository;
}
public function __invoke(RequestGenerationMessage $message)
{
if (null === $template = $this->docGeneratorTemplateRepository->find($message->getTemplateId())) {
throw new \RuntimeException('template not found: ' . $message->getTemplateId());
}
if (null === $destinationStoredObject = $this->storedObjectRepository->find($message->getDestinationStoredObjectId())) {
throw new \RuntimeException('destination stored object not found : ' . $message->getDestinationStoredObjectId());
}
if ($destinationStoredObject->getGenerationTrialsCounter() >= self::AUTHORIZED_TRIALS) {
throw new UnrecoverableMessageHandlingException('maximum number of retry reached');
}
$destinationStoredObject->addGenerationTrial();
$this->entityManager->createQuery('UPDATE '.StoredObject::class.' s SET s.generationTrialsCounter = s.generationTrialsCounter + 1 WHERE s.id = :id')
->setParameter('id', $destinationStoredObject->getId())
->execute();
$this->generator->generateDocFromTemplate(
$template,
$message->getEntityId(),
$message->getContextGenerationData(),
$destinationStoredObject
);
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace Chill\DocGeneratorBundle\Service\Messenger;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\User;
class RequestGenerationMessage
{
private int $creatorId;
private int $templateId;
private int $entityId;
private int $destinationStoredObjectId;
private array $contextGenerationData;
public function __construct(
User $creator,
DocGeneratorTemplate $template,
int $entityId,
StoredObject $destinationStoredObject,
array $contextGenerationData
) {
$this->creatorId = $creator->getId();
$this->templateId = $template->getId();
$this->entityId = $entityId;
$this->destinationStoredObjectId = $destinationStoredObject->getId();
$this->contextGenerationData = $contextGenerationData;
}
public function getCreatorId(): int
{
return $this->creatorId;
}
public function getDestinationStoredObjectId(): int
{
return $this->destinationStoredObjectId;
}
public function getTemplateId(): int
{
return $this->templateId;
}
public function getEntityId(): int
{
return $this->entityId;
}
public function getContextGenerationData(): array
{
return $this->contextGenerationData;
}
}

View File

@ -20,10 +20,14 @@ services:
resource: '../Serializer/Normalizer/' resource: '../Serializer/Normalizer/'
tags: tags:
- { name: 'serializer.normalizer', priority: -152 } - { name: 'serializer.normalizer', priority: -152 }
Chill\DocGeneratorBundle\Serializer\Normalizer\CollectionDocGenNormalizer: Chill\DocGeneratorBundle\Serializer\Normalizer\CollectionDocGenNormalizer:
tags: tags:
- { name: 'serializer.normalizer', priority: -126 } - { name: 'serializer.normalizer', priority: -126 }
Chill\DocGeneratorBundle\Service\Context\:
resource: "../Service/Context"
Chill\DocGeneratorBundle\Controller\: Chill\DocGeneratorBundle\Controller\:
resource: "../Controller" resource: "../Controller"
autowire: true autowire: true
@ -34,18 +38,20 @@ services:
autowire: true autowire: true
autoconfigure: true autoconfigure: true
Chill\DocGeneratorBundle\Service\Context\:
resource: "../Service/Context/"
autowire: true
autoconfigure: true
Chill\DocGeneratorBundle\GeneratorDriver\: Chill\DocGeneratorBundle\GeneratorDriver\:
resource: "../GeneratorDriver/" resource: "../GeneratorDriver/"
autowire: true autowire: true
autoconfigure: true autoconfigure: true
Chill\DocGeneratorBundle\Service\Messenger\:
resource: "../Service/Messenger/"
Chill\DocGeneratorBundle\Service\Generator\Generator: ~
Chill\DocGeneratorBundle\Service\Generator\GeneratorInterface: '@Chill\DocGeneratorBundle\Service\Generator\Generator'
Chill\DocGeneratorBundle\Driver\RelatorioDriver: '@Chill\DocGeneratorBundle\Driver\DriverInterface' Chill\DocGeneratorBundle\Driver\RelatorioDriver: '@Chill\DocGeneratorBundle\Driver\DriverInterface'
Chill\DocGeneratorBundle\Context\ContextManager: Chill\DocGeneratorBundle\Context\ContextManager:
arguments: arguments:
$contexts: !tagged_iterator { tag: chill_docgen.context, default_index_method: getKey } $contexts: !tagged_iterator { tag: chill_docgen.context, default_index_method: getKey }
Chill\DocGeneratorBundle\Context\ContextManagerInterface: '@Chill\DocGeneratorBundle\Context\ContextManager'

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\DocGenerator;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20230214192558 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add status, template_id and fix defaults on chill_doc.stored_object';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_doc.stored_object ADD template_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE chill_doc.stored_object ADD status TEXT DEFAULT \'ready\' NOT NULL');
$this->addSql('ALTER TABLE chill_doc.stored_object ADD createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('UPDATE chill_doc.stored_object SET createdAt = creation_date');
$this->addSql('ALTER TABLE chill_doc.stored_object ADD createdBy_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE chill_doc.stored_object DROP creation_date;');
$this->addSql('ALTER TABLE chill_doc.stored_object ALTER type SET DEFAULT \'\'');
$this->addSql('ALTER TABLE chill_doc.stored_object ALTER title DROP DEFAULT');
$this->addSql('COMMENT ON COLUMN chill_doc.stored_object.createdAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE chill_doc.stored_object ADD CONSTRAINT FK_49604E365DA0FB8 FOREIGN KEY (template_id) REFERENCES chill_docgen_template (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_doc.stored_object ADD CONSTRAINT FK_49604E363174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_49604E365DA0FB8 ON chill_doc.stored_object (template_id)');
$this->addSql('CREATE INDEX IDX_49604E363174800F ON chill_doc.stored_object (createdBy_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_doc.stored_object DROP CONSTRAINT FK_49604E365DA0FB8');
$this->addSql('ALTER TABLE chill_doc.stored_object DROP CONSTRAINT FK_49604E363174800F');
$this->addSql('ALTER TABLE chill_doc.stored_object DROP template_id');
$this->addSql('ALTER TABLE chill_doc.stored_object DROP status');
$this->addSql('ALTER TABLE chill_doc.stored_object ADD creation_date TIMESTAMP(0) DEFAULT NOW()');
$this->addSql('UPDATE chill_doc.stored_object SET creation_date = createdAt');
$this->addSql('ALTER TABLE chill_doc.stored_object DROP createdAt');
$this->addSql('ALTER TABLE chill_doc.stored_object DROP createdBy_id');
$this->addSql('ALTER TABLE chill_doc.stored_object ALTER title SET DEFAULT \'\'');
$this->addSql('ALTER TABLE chill_doc.stored_object ALTER type DROP DEFAULT');
}
}

View File

@ -0,0 +1,141 @@
<?php
namespace Chill\DocGeneratorBundle\tests\Service\Context\Generator;
use Chill\DocGeneratorBundle\Context\ContextManagerInterface;
use Chill\DocGeneratorBundle\Context\DocGeneratorContextInterface;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocGeneratorBundle\GeneratorDriver\DriverInterface;
use Chill\DocGeneratorBundle\Service\Generator\Generator;
use Chill\DocGeneratorBundle\Service\Generator\ObjectReadyException;
use Chill\DocGeneratorBundle\Service\Generator\RelatedEntityNotFoundException;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
class GeneratorTest extends TestCase
{
use ProphecyTrait;
public function testSuccessfulGeneration(): void
{
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject())
->setType('application/test'));
$destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_PENDING);
$reflection = new \ReflectionClass($destinationStoredObject);
$reflection->getProperty('id')->setAccessible(true);
$reflection->getProperty('id')->setValue($destinationStoredObject, 1);
$entity = new class {};
$data = [];
$context = $this->prophesize(DocGeneratorContextInterface::class);
$context->getData($template, $entity, Argument::type('array'))->willReturn($data);
$context->getName()->willReturn('dummy_context');
$context->getEntityClass()->willReturn('DummyClass');
$context = $context->reveal();
$contextManagerInterface = $this->prophesize(ContextManagerInterface::class);
$contextManagerInterface->getContextByDocGeneratorTemplate($template)
->willReturn($context);
$driver = $this->prophesize(DriverInterface::class);
$driver->generateFromString('template', 'application/test', $data, Argument::any())
->willReturn('generated');
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->find(StoredObject::class, 1)
->willReturn($destinationStoredObject);
$entityManager->find('DummyClass', Argument::type('int'))
->willReturn($entity);
$entityManager->clear()->shouldBeCalled();
$entityManager->flush()->shouldBeCalled();
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$storedObjectManager->read($templateStoredObject)->willReturn('template');
$storedObjectManager->write($destinationStoredObject, 'generated')->shouldBeCalled();
$generator = new Generator(
$contextManagerInterface->reveal(),
$driver->reveal(),
$entityManager->reveal(),
new NullLogger(),
$storedObjectManager->reveal()
);
$generator->generateDocFromTemplate(
$template,
1,
[],
$destinationStoredObject
);
}
public function testPreventRegenerateDocument(): void
{
$this->expectException(ObjectReadyException::class);
$generator = new Generator(
$this->prophesize(ContextManagerInterface::class)->reveal(),
$this->prophesize(DriverInterface::class)->reveal(),
$this->prophesize(EntityManagerInterface::class)->reveal(),
new NullLogger(),
$this->prophesize(StoredObjectManagerInterface::class)->reveal()
);
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject())
->setType('application/test'));
$destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_READY);
$generator->generateDocFromTemplate(
$template,
1,
[],
$destinationStoredObject
);
}
public function testRelatedEntityNotFound(): void
{
$this->expectException(RelatedEntityNotFoundException::class);
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject())
->setType('application/test'));
$destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_PENDING);
$reflection = new \ReflectionClass($destinationStoredObject);
$reflection->getProperty('id')->setAccessible(true);
$reflection->getProperty('id')->setValue($destinationStoredObject, 1);
$context = $this->prophesize(DocGeneratorContextInterface::class);
$context->getName()->willReturn('dummy_context');
$context->getEntityClass()->willReturn('DummyClass');
$context = $context->reveal();
$contextManagerInterface = $this->prophesize(ContextManagerInterface::class);
$contextManagerInterface->getContextByDocGeneratorTemplate($template)
->willReturn($context);
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->find(Argument::type('string'), Argument::type('int'))
->willReturn(null);
$generator = new Generator(
$contextManagerInterface->reveal(),
$this->prophesize(DriverInterface::class)->reveal(),
$entityManager->reveal(),
new NullLogger(),
$this->prophesize(StoredObjectManagerInterface::class)->reveal()
);
$generator->generateDocFromTemplate(
$template,
1,
[],
$destinationStoredObject
);
}
}

View File

@ -10,6 +10,16 @@ docgen:
test generate: Tester la génération test generate: Tester la génération
With context %name%: 'Avec le contexte "%name%"' With context %name%: 'Avec le contexte "%name%"'
Doc generation failed: La génération de ce document a échoué
Doc generation is pending: La génération de ce document est en cours
Come back later: Revenir plus tard
failure_email:
The generation of a document failed: La génération d'un document a échoué
The generation of the document {template_name} failed: La génération d'un document à partir du modèle {{ template_name }} a échoué.
The following errors were encoutered: Les erreurs suivantes ont été rencontrées
Forward this email to your administrator for solving: Faites suivre ce message vers votre administrateur pour la résolution du problème.
References: Références
crud: crud:
docgen_template: docgen_template:

View File

@ -0,0 +1,39 @@
<?php
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\Entity\StoredObject;
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;
class StoredObjectApiController
{
private Security $security;
public function __construct(Security $security)
{
$this->security = $security;
}
/**
* @Route("/api/1.0/doc-store/stored-object/{uuid}/is-ready")
*/
public function isDocumentReady(StoredObject $storedObject): Response
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException();
}
return new JsonResponse(
[
'id' => $storedObject->getId(),
'filename' => $storedObject->getFilename(),
'status' => $storedObject->getStatus(),
'type' => $storedObject->getType(),
]
);
}
}

View File

@ -14,6 +14,9 @@ namespace Chill\DocStoreBundle\Entity;
use ChampsLibres\AsyncUploaderBundle\Model\AsyncFileInterface; use ChampsLibres\AsyncUploaderBundle\Model\AsyncFileInterface;
use ChampsLibres\AsyncUploaderBundle\Validator\Constraints\AsyncFileExists; use ChampsLibres\AsyncUploaderBundle\Validator\Constraints\AsyncFileExists;
use ChampsLibres\WopiLib\Contract\Entity\Document; use ChampsLibres\WopiLib\Contract\Entity\Document;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use DateTime; use DateTime;
use DateTimeInterface; use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
@ -30,13 +33,13 @@ use Symfony\Component\Serializer\Annotation as Serializer;
* message="The file is not stored properly" * message="The file is not stored properly"
* ) * )
*/ */
class StoredObject implements AsyncFileInterface, Document class StoredObject implements AsyncFileInterface, Document, TrackCreationInterface
{ {
/** public const STATUS_READY = "ready";
* @ORM\Column(type="datetime", name="creation_date") public const STATUS_PENDING = "pending";
* @Serializer\Groups({"read", "write"}) public const STATUS_FAILURE = "failure";
*/
private DateTimeInterface $creationDate; use TrackCreationTrait;
/** /**
* @ORM\Column(type="json", name="datas") * @ORM\Column(type="json", name="datas")
@ -48,7 +51,7 @@ class StoredObject implements AsyncFileInterface, Document
* @ORM\Column(type="text") * @ORM\Column(type="text")
* @Serializer\Groups({"read", "write"}) * @Serializer\Groups({"read", "write"})
*/ */
private $filename; private string $filename = '';
/** /**
* @ORM\Id * @ORM\Id
@ -56,7 +59,7 @@ class StoredObject implements AsyncFileInterface, Document
* @ORM\Column(type="integer") * @ORM\Column(type="integer")
* @Serializer\Groups({"read", "write"}) * @Serializer\Groups({"read", "write"})
*/ */
private $id; private ?int $id;
/** /**
* @var int[] * @var int[]
@ -78,7 +81,7 @@ class StoredObject implements AsyncFileInterface, Document
private string $title = ''; private string $title = '';
/** /**
* @ORM\Column(type="text", name="type") * @ORM\Column(type="text", name="type", options={"default": ""})
* @Serializer\Groups({"read", "write"}) * @Serializer\Groups({"read", "write"})
*/ */
private string $type = ''; private string $type = '';
@ -89,28 +92,68 @@ class StoredObject implements AsyncFileInterface, Document
*/ */
private UuidInterface $uuid; private UuidInterface $uuid;
public function __construct() /**
* @ORM\ManyToOne(targetEntity=DocGeneratorTemplate::class)
*/
private ?DocGeneratorTemplate $template;
/**
* @ORM\Column(type="text", options={"default": "ready"})
* @Serializer\Groups({"read"})
*/
private string $status;
/**
* Store the number of times a generation has been tryied for this StoredObject.
*
* This is a workaround, as generation consume lot of memory, and out-of-memory errors
* are not handled by messenger.
*
* @ORM\Column(type="integer", options={"default": 0})
*/
private int $generationTrialsCounter = 0;
/**
* @param StoredObject::STATUS_* $status
*/
public function __construct(string $status = "ready")
{ {
$this->creationDate = new DateTime();
$this->uuid = Uuid::uuid4(); $this->uuid = Uuid::uuid4();
$this->status = $status;
} }
public function addGenerationTrial(): self
{
$this->generationTrialsCounter++;
return $this;
}
/**
* @Serializer\Groups({"read", "write"})
* @deprecated
*/
public function getCreationDate(): DateTime public function getCreationDate(): DateTime
{ {
return $this->creationDate; return DateTime::createFromImmutable($this->createdAt);
} }
public function getDatas() public function getDatas(): array
{ {
return $this->datas; return $this->datas;
} }
public function getFilename() public function getFilename(): string
{ {
return $this->filename; return $this->filename;
} }
public function getId() public function getGenerationTrialsCounter(): int
{
return $this->generationTrialsCounter;
}
public function getId(): ?int
{ {
return $this->id; return $this->id;
} }
@ -133,6 +176,14 @@ class StoredObject implements AsyncFileInterface, Document
return $this->getFilename(); return $this->getFilename();
} }
/**
* @return StoredObject::STATUS_*
*/
public function getStatus(): string
{
return $this->status;
}
public function getTitle() public function getTitle()
{ {
return $this->title; return $this->title;
@ -153,52 +204,92 @@ class StoredObject implements AsyncFileInterface, Document
return (string) $this->uuid; return (string) $this->uuid;
} }
public function setCreationDate(DateTime $creationDate) /**
* @Serializer\Groups({"write"})
* @deprecated
*/
public function setCreationDate(DateTime $creationDate): self
{ {
$this->creationDate = $creationDate; $this->createdAt = \DateTimeImmutable::createFromMutable($creationDate);
return $this; return $this;
} }
public function setDatas(?array $datas) public function setDatas(?array $datas): self
{ {
$this->datas = (array) $datas; $this->datas = (array) $datas;
return $this; return $this;
} }
public function setFilename(?string $filename) public function setFilename(?string $filename): self
{ {
$this->filename = (string) $filename; $this->filename = (string) $filename;
return $this; return $this;
} }
public function setIv(?array $iv) public function setIv(?array $iv): self
{ {
$this->iv = (array) $iv; $this->iv = (array) $iv;
return $this; return $this;
} }
public function setKeyInfos(?array $keyInfos) public function setKeyInfos(?array $keyInfos): self
{ {
$this->keyInfos = (array) $keyInfos; $this->keyInfos = (array) $keyInfos;
return $this; return $this;
} }
public function setTitle(?string $title) /**
* @param StoredObject::STATUS_* $status
*/
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
public function setTitle(?string $title): self
{ {
$this->title = (string) $title; $this->title = (string) $title;
return $this; return $this;
} }
public function setType(?string $type) public function setType(?string $type): self
{ {
$this->type = (string) $type; $this->type = (string) $type;
return $this; return $this;
} }
public function getTemplate(): ?DocGeneratorTemplate
{
return $this->template;
}
public function hasTemplate(): bool
{
return null !== $this->template;
}
public function setTemplate(?DocGeneratorTemplate $template): StoredObject
{
$this->template = $template;
return $this;
}
public function isPending(): bool
{
return self::STATUS_PENDING === $this->getStatus();
}
public function isFailure(): bool
{
return self::STATUS_FAILURE === $this->getStatus();
}
} }

View File

@ -1,7 +1,8 @@
import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n"; import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.vue"; import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.vue";
import {createApp} from "vue"; import {createApp} from "vue";
import {StoredObject} from "../../types"; import {StoredObject, StoredObjectStatusChange} from "../../types";
import {is_object_ready} from "../../vuejs/StoredObjectButton/helpers";
const i18n = _createI18n({}); const i18n = _createI18n({});
@ -15,19 +16,32 @@ window.addEventListener('DOMContentLoaded', function (e) {
filename: string, filename: string,
canEdit: string, canEdit: string,
storedObject: string, storedObject: string,
small: string, buttonSmall: string,
}; };
const const
storedObject = JSON.parse(datasets.storedObject), storedObject = JSON.parse(datasets.storedObject) as StoredObject,
filename = datasets.filename, filename = datasets.filename,
canEdit = datasets.canEdit === '1', canEdit = datasets.canEdit === '1',
small = datasets.small === '1' small = datasets.buttonSmall === '1'
; ;
return { storedObject, filename, canEdit, small }; return { storedObject, filename, canEdit, small };
}, },
template: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small"></document-action-buttons-group>', template: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" @on-stored-object-status-change="onStoredObjectStatusChange"></document-action-buttons-group>',
methods: {
onStoredObjectStatusChange: function(newStatus: StoredObjectStatusChange): void {
this.$data.storedObject.status = newStatus.status;
this.$data.storedObject.filename = newStatus.filename;
this.$data.storedObject.type = newStatus.type;
// remove eventual div which inform pending status
document.querySelectorAll(`[data-docgen-is-pending="${this.$data.storedObject.id}"]`)
.forEach(function(el) {
el.remove();
});
}
}
}); });
app.use(i18n).mount(el); app.use(i18n).mount(el);

View File

@ -1,5 +1,7 @@
import {DateTime} from "../../../ChillMainBundle/Resources/public/types"; import {DateTime} from "../../../ChillMainBundle/Resources/public/types";
export type StoredObjectStatus = "ready"|"failure"|"pending";
export interface StoredObject { export interface StoredObject {
id: number, id: number,
@ -13,7 +15,15 @@ export interface StoredObject {
keyInfos: object, keyInfos: object,
title: string, title: string,
type: string, type: string,
uuid: string uuid: string,
status: StoredObjectStatus,
}
export interface StoredObjectStatusChange {
id: number,
filename: string,
status: StoredObjectStatus,
type: string,
} }
/** /**

View File

@ -1,13 +1,13 @@
<template> <template>
<div class="dropdown"> <div v-if="'ready' === props.storedObject.status" class="dropdown">
<button :class="Object.assign({'btn': true, 'btn-outline-primary': true, 'dropdown-toggle': true, small: props.small})" type="button" data-bs-toggle="dropdown" aria-expanded="false"> <button :class="Object.assign({'btn': true, 'btn-outline-primary': true, 'dropdown-toggle': true, 'btn-sm': props.small})" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Actions Actions
</button> </button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type)"> <li v-if="props.canEdit && is_extension_editable(props.storedObject.type)">
<wopi-edit-button :stored-object="props.storedObject" :classes="{'dropdown-item': true}" :execute-before-leave="props.executeBeforeLeave"></wopi-edit-button> <wopi-edit-button :stored-object="props.storedObject" :classes="{'dropdown-item': true}" :execute-before-leave="props.executeBeforeLeave"></wopi-edit-button>
</li> </li>
<li v-if="props.storedObject.type != 'application/pdf' && props.canConvertPdf"> <li v-if="props.storedObject.type != 'application/pdf' && is_extension_viewable(props.storedObject.type) && props.canConvertPdf">
<convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button> <convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button>
</li> </li>
<li v-if="props.canDownload"> <li v-if="props.canDownload">
@ -15,16 +15,27 @@
</li> </li>
</ul> </ul>
</div> </div>
<div v-else-if="'pending' === props.storedObject.status">
<div class="btn btn-outline-info">Génération en cours</div>
</div>
<div v-else-if="'failure' === props.storedObject.status">
<div class="btn btn-outline-danger">La génération a échoué</div>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import {onMounted} from "vue";
import ConvertButton from "./StoredObjectButton/ConvertButton.vue"; import ConvertButton from "./StoredObjectButton/ConvertButton.vue";
import DownloadButton from "./StoredObjectButton/DownloadButton.vue"; import DownloadButton from "./StoredObjectButton/DownloadButton.vue";
import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue"; import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue";
import {is_extension_editable} from "./StoredObjectButton/helpers"; import {is_extension_editable, is_extension_viewable, is_object_ready} from "./StoredObjectButton/helpers";
import {StoredObject, WopiEditButtonExecutableBeforeLeaveFunction} from "../types"; import {
StoredObject,
StoredObjectStatusChange,
WopiEditButtonExecutableBeforeLeaveFunction
} from "../types";
interface DocumentActionButtonsGroupConfig { interface DocumentActionButtonsGroupConfig {
storedObject: StoredObject, storedObject: StoredObject,
@ -48,6 +59,10 @@ interface DocumentActionButtonsGroupConfig {
executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction, executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction,
} }
const emit = defineEmits<{
(e: 'onStoredObjectStatusChange', newStatus: StoredObjectStatusChange): void
}>();
const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), { const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), {
small: false, small: false,
canEdit: true, canEdit: true,
@ -56,6 +71,51 @@ const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), {
returnPath: window.location.pathname + window.location.search + window.location.hash, returnPath: window.location.pathname + window.location.search + window.location.hash,
}); });
/**
* counter for the number of times that we check for a new status
*/
let tryiesForReady = 0;
/**
* how many times we may check for a new status, once loaded
*/
const maxTryiesForReady = 120;
const checkForReady = function(): void {
if (
'ready' === props.storedObject.status
|| 'failure' === props.storedObject.status
// stop reloading if the page stays opened for a long time
|| tryiesForReady > maxTryiesForReady
) {
return;
}
tryiesForReady = tryiesForReady + 1;
setTimeout(onObjectNewStatusCallback, 5000);
};
const onObjectNewStatusCallback = async function(): Promise<void> {
const new_status = await is_object_ready(props.storedObject);
if (props.storedObject.status !== new_status.status) {
emit('onStoredObjectStatusChange', new_status);
return Promise.resolve();
} else if ('failure' === new_status.status) {
return Promise.resolve();
}
if ('ready' !== new_status.status) {
// we check for new status, unless it is ready
checkForReady();
}
return Promise.resolve();
};
onMounted(() => {
checkForReady();
})
</script> </script>
<style scoped> <style scoped>

View File

@ -1,97 +1,108 @@
import {StoredObject, StoredObjectStatus, StoredObjectStatusChange} from "../../types";
const SUPPORTED_MIMES = new Set([ const MIMES_EDIT = new Set([
'image/svg+xml',
'application/vnd.ms-powerpoint', 'application/vnd.ms-powerpoint',
'application/vnd.ms-excel', 'application/vnd.ms-excel',
'application/vnd.sun.xml.writer',
'application/vnd.oasis.opendocument.text', 'application/vnd.oasis.opendocument.text',
'application/vnd.oasis.opendocument.text-flat-xml', 'application/vnd.oasis.opendocument.text-flat-xml',
'application/vnd.sun.xml.calc',
'application/vnd.oasis.opendocument.spreadsheet', 'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.oasis.opendocument.spreadsheet-flat-xml', 'application/vnd.oasis.opendocument.spreadsheet-flat-xml',
'application/vnd.sun.xml.impress',
'application/vnd.oasis.opendocument.presentation', 'application/vnd.oasis.opendocument.presentation',
'application/vnd.oasis.opendocument.presentation-flat-xml', 'application/vnd.oasis.opendocument.presentation-flat-xml',
'application/vnd.sun.xml.draw',
'application/vnd.oasis.opendocument.graphics', 'application/vnd.oasis.opendocument.graphics',
'application/vnd.oasis.opendocument.graphics-flat-xml', 'application/vnd.oasis.opendocument.graphics-flat-xml',
'application/vnd.oasis.opendocument.chart', 'application/vnd.oasis.opendocument.chart',
'application/vnd.sun.xml.writer.global',
'application/vnd.oasis.opendocument.text-master',
'application/vnd.sun.xml.writer.template',
'application/vnd.oasis.opendocument.text-template',
'application/vnd.oasis.opendocument.text-master-template',
'application/vnd.sun.xml.calc.template',
'application/vnd.oasis.opendocument.spreadsheet-template',
'application/vnd.sun.xml.impress.template',
'application/vnd.oasis.opendocument.presentation-template',
'application/vnd.sun.xml.draw.template',
'application/vnd.oasis.opendocument.graphics-template',
'application/msword',
'application/msword', 'application/msword',
'application/vnd.ms-excel', 'application/vnd.ms-excel',
'application/vnd.ms-powerpoint', 'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-word.document.macroEnabled.12', 'application/vnd.ms-word.document.macroEnabled.12',
'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
'application/vnd.ms-word.template.macroEnabled.12',
'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
'application/vnd.ms-excel.template.macroEnabled.12',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel.sheet.binary.macroEnabled.12', 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
'application/vnd.ms-excel.sheet.macroEnabled.12', 'application/vnd.ms-excel.sheet.macroEnabled.12',
'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.ms-powerpoint.presentation.macroEnabled.12', 'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
'application/vnd.openxmlformats-officedocument.presentationml.template',
'application/vnd.ms-powerpoint.template.macroEnabled.12',
'application/vnd.wordperfect',
'application/x-aportisdoc',
'application/x-hwp',
'application/vnd.ms-works',
'application/x-mswrite',
'application/x-dif-document', 'application/x-dif-document',
'text/spreadsheet', 'text/spreadsheet',
'text/csv', 'text/csv',
'application/x-dbase', 'application/x-dbase',
'application/vnd.lotus-1-2-3',
'image/cgm',
'image/vnd.dxf',
'image/x-emf',
'image/x-wmf',
'application/coreldraw',
'application/vnd.visio2013',
'application/vnd.visio',
'application/vnd.ms-visio.drawing',
'application/x-mspublisher',
'application/x-sony-bbeb',
'application/x-gnumeric',
'application/macwriteii',
'application/x-iwork-numbers-sffnumbers',
'application/vnd.oasis.opendocument.text-web',
'application/x-pagemaker',
'text/rtf', 'text/rtf',
'text/plain', 'text/plain',
'application/x-fictionbook+xml',
'application/clarisworks',
'image/x-wpg',
'application/x-iwork-pages-sffpages',
'application/vnd.openxmlformats-officedocument.presentationml.slideshow', 'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
'application/x-iwork-keynote-sffkey',
'application/x-abiword',
'image/x-freehand',
'application/vnd.sun.xml.chart',
'application/x-t602',
'image/bmp',
'image/png',
'image/gif',
'image/tiff',
'image/jpg',
'image/jpeg',
'application/pdf',
]); ]);
const MIMES_VIEW = new Set([
...MIMES_EDIT,
[
'image/svg+xml',
'application/vnd.sun.xml.writer',
'application/vnd.sun.xml.calc',
'application/vnd.sun.xml.impress',
'application/vnd.sun.xml.draw',
'application/vnd.sun.xml.writer.global',
'application/vnd.sun.xml.writer.template',
'application/vnd.sun.xml.calc.template',
'application/vnd.sun.xml.impress.template',
'application/vnd.sun.xml.draw.template',
'application/vnd.oasis.opendocument.text-master',
'application/vnd.oasis.opendocument.text-template',
'application/vnd.oasis.opendocument.text-master-template',
'application/vnd.oasis.opendocument.spreadsheet-template',
'application/vnd.oasis.opendocument.presentation-template',
'application/vnd.oasis.opendocument.graphics-template',
'application/vnd.ms-word.template.macroEnabled.12',
'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
'application/vnd.ms-excel.template.macroEnabled.12',
'application/vnd.openxmlformats-officedocument.presentationml.template',
'application/vnd.ms-powerpoint.template.macroEnabled.12',
'application/vnd.wordperfect',
'application/x-aportisdoc',
'application/x-hwp',
'application/vnd.ms-works',
'application/x-mswrite',
'application/vnd.lotus-1-2-3',
'image/cgm',
'image/vnd.dxf',
'image/x-emf',
'image/x-wmf',
'application/coreldraw',
'application/vnd.visio2013',
'application/vnd.visio',
'application/vnd.ms-visio.drawing',
'application/x-mspublisher',
'application/x-sony-bbeb',
'application/x-gnumeric',
'application/macwriteii',
'application/x-iwork-numbers-sffnumbers',
'application/vnd.oasis.opendocument.text-web',
'application/x-pagemaker',
'application/x-fictionbook+xml',
'application/clarisworks',
'image/x-wpg',
'application/x-iwork-pages-sffpages',
'application/x-iwork-keynote-sffkey',
'application/x-abiword',
'image/x-freehand',
'application/vnd.sun.xml.chart',
'application/x-t602',
'image/bmp',
'image/png',
'image/gif',
'image/tiff',
'image/jpg',
'image/jpeg',
'application/pdf',
]
])
function is_extension_editable(mimeType: string): boolean { function is_extension_editable(mimeType: string): boolean {
return SUPPORTED_MIMES.has(mimeType); return MIMES_EDIT.has(mimeType);
}
function is_extension_viewable(mimeType: string): boolean {
return MIMES_VIEW.has(mimeType);
} }
function build_convert_link(uuid: string) { function build_convert_link(uuid: string) {
@ -158,6 +169,18 @@ async function download_and_decrypt_doc(urlGenerator: string, keyData: JsonWebKe
} }
} }
async function is_object_ready(storedObject: StoredObject): Promise<StoredObjectStatusChange>
{
const new_status_response = await window
.fetch( `/api/1.0/doc-store/stored-object/${storedObject.uuid}/is-ready`);
if (!new_status_response.ok) {
throw new Error("could not fetch the new status");
}
return await new_status_response.json();
}
export { export {
build_convert_link, build_convert_link,
build_download_info_link, build_download_info_link,
@ -165,4 +188,6 @@ export {
download_and_decrypt_doc, download_and_decrypt_doc,
download_doc, download_doc,
is_extension_editable, is_extension_editable,
is_extension_viewable,
is_object_ready,
}; };

View File

@ -9,11 +9,6 @@
<dt>{{ 'Title'|trans }}</dt> <dt>{{ 'Title'|trans }}</dt>
<dd>{{ document.title }}</dd> <dd>{{ document.title }}</dd>
{% if document.scope is not null %}
<dt>{{ 'Scope' | trans }}</dt>
<dd>{{ document.scope.name | localize_translatable_string }}</dd>
{% endif %}
<dt>{{ 'Category'|trans }}</dt> <dt>{{ 'Category'|trans }}</dt>
<dd>{{ document.category.name|localize_translatable_string }}</dd> <dd>{{ document.category.name|localize_translatable_string }}</dd>

View File

@ -5,18 +5,25 @@
<div class="item-bloc"> <div class="item-bloc">
<div class="item-row"> <div class="item-row">
<div class="item-col" style="width: unset"> <div class="item-col" style="width: unset">
{% if document.object.isPending %}
<div class="badge text-bg-info" data-docgen-is-pending="{{ document.object.id }}">{{ 'docgen.Doc generation is pending'|trans }}</div>
{% elseif document.object.isFailure %}
<div class="badge text-bg-warning">{{ 'docgen.Doc generation failed'|trans }}</div>
{% endif %}
<div class="denomination h2"> <div class="denomination h2">
{{ document.title }} {{ document.title }}
</div> </div>
<div> {% if document.object.type is not empty %}
{{ mm.mimeIcon(document.object.type) }} <div>
</div> {{ mm.mimeIcon(document.object.type) }}
</div>
{% endif %}
<div> <div>
<p>{{ document.category.name|localize_translatable_string }}</p> <p>{{ document.category.name|localize_translatable_string }}</p>
</div> </div>
{% if document.template is not null %} {% if document.object.hasTemplate %}
<div> <div>
<p>{{ document.template.name.fr }}</p> <p>{{ document.object.template.name|localize_translatable_string }}</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@ -157,7 +157,7 @@ final class WopiEditTwigExtensionRuntime implements RuntimeExtensionInterface
'document_json' => $this->normalizer->normalize($document, 'json', [AbstractNormalizer::GROUPS => ['read']]), 'document_json' => $this->normalizer->normalize($document, 'json', [AbstractNormalizer::GROUPS => ['read']]),
'title' => $title, 'title' => $title,
'can_edit' => $canEdit, 'can_edit' => $canEdit,
'options' => array_merge($options, self::DEFAULT_OPTIONS_TEMPLATE_BUTTON_GROUP), 'options' => array_merge(self::DEFAULT_OPTIONS_TEMPLATE_BUTTON_GROUP, $options),
]); ]);
} }

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\DocStore;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20230227161327 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add a generation counter on doc store';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_doc.stored_object ADD generationTrialsCounter INT DEFAULT 0 NOT NULL;');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_doc.stored_object DROP generationTrialsCounter');
}
}

View File

@ -0,0 +1,65 @@
<?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\Form\AbsenceType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
class AbsenceController extends AbstractController
{
/**
* @Route(
* "/{_locale}/absence",
* name="chill_main_user_absence_index",
* methods={"GET", "POST"}
* )
*/
public function setAbsence(Request $request)
{
$user = $this->getUser();
$form = $this->createForm(AbsenceType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em = $this->getDoctrine()->getManager();
$em->flush();
return $this->redirect($this->generateUrl('chill_main_user_absence_index'));
}
return $this->render('@ChillMain/Menu/absence.html.twig', [
'user' => $user,
'form' => $form->createView(),
]);
}
/**
* @Route(
* "/{_locale}/absence/unset",
* name="chill_main_user_absence_unset",
* methods={"GET", "POST"}
* )
*/
public function unsetAbsence(Request $request)
{
$user = $this->getUser();
$user->setAbsenceStart(null);
$em = $this->getDoctrine()->getManager();
$em->flush();
return $this->redirect($this->generateUrl('chill_main_user_absence_index'));
}
}

View File

@ -28,7 +28,7 @@ class LocationController extends CRUDController
protected function customizeQuery(string $action, Request $request, $query): void protected function customizeQuery(string $action, Request $request, $query): void
{ {
$query->where('e.availableForUsers = true'); //TODO not working $query->where('e.availableForUsers = "TRUE"');
} }
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator) protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)

View File

@ -11,14 +11,15 @@ declare(strict_types=1);
namespace Chill\MainBundle\Entity; namespace Chill\MainBundle\Entity;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use RuntimeException; use RuntimeException;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation as Serializer; use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use function in_array; use function in_array;
/** /**
@ -40,6 +41,11 @@ class User implements UserInterface
*/ */
protected ?int $id = null; protected ?int $id = null;
/**
* @ORM\Column(type="datetime_immutable", nullable=true)
*/
private ?DateTimeImmutable $absenceStart = null;
/** /**
* Array where SAML attributes's data are stored. * Array where SAML attributes's data are stored.
* *
@ -173,6 +179,11 @@ class User implements UserInterface
{ {
} }
public function getAbsenceStart(): ?DateTimeImmutable
{
return $this->absenceStart;
}
/** /**
* Get attributes. * Get attributes.
* *
@ -291,6 +302,11 @@ class User implements UserInterface
return $this->usernameCanonical; return $this->usernameCanonical;
} }
public function isAbsent(): bool
{
return null !== $this->getAbsenceStart() && $this->getAbsenceStart() <= new DateTimeImmutable('now');
}
/** /**
* @return bool * @return bool
*/ */
@ -355,6 +371,11 @@ class User implements UserInterface
} }
} }
public function setAbsenceStart(?DateTimeImmutable $absenceStart): void
{
$this->absenceStart = $absenceStart;
}
public function setAttributeByDomain(string $domain, string $key, $value): self public function setAttributeByDomain(string $domain, string $key, $value): self
{ {
$this->attributes[$domain][$key] = $value; $this->attributes[$domain][$key] = $value;

View File

@ -83,9 +83,9 @@ interface ExportInterface extends ExportElementInterface
* *
* @param string $key The column key, as added in the query * @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 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 mixed $data The data from the export's form (as defined in `buildForm` * @param mixed $data The data from the export's form (as defined in `buildForm`)
* *
* @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 pure-callable(null|string|int|float|'_header' $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(); }`
*/ */
public function getLabels($key, array $values, $data); public function getLabels($key, array $values, $data);

View File

@ -35,6 +35,10 @@ class DateTimeHelper
return ''; return '';
} }
if ($value instanceof \DateTimeInterface) {
return $value;
}
// warning: won't work with DateTimeImmutable as we reset time a few lines later // warning: won't work with DateTimeImmutable as we reset time a few lines later
$date = DateTime::createFromFormat('Y-m-d', $value); $date = DateTime::createFromFormat('Y-m-d', $value);
$hasTime = false; $hasTime = false;

View File

@ -0,0 +1,38 @@
<?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\Entity\User;
use Chill\MainBundle\Form\Type\ChillDateType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AbsenceType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('absenceStart', ChillDateType::class, [
'required' => true,
'input' => 'datetime_immutable',
'label' => 'absence.Absence start',
]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}

View File

@ -15,6 +15,7 @@ use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Location; use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Entity\Scope; use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\UserJob; use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\PickCivilityType; use Chill\MainBundle\Form\Type\PickCivilityType;
use Chill\MainBundle\Templating\TranslatableStringHelper; use Chill\MainBundle\Templating\TranslatableStringHelper;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
@ -110,6 +111,11 @@ class UserType extends AbstractType
return $qb; return $qb;
}, },
])
->add('absenceStart', ChillDateType::class, [
'required' => false,
'input' => 'datetime_immutable',
'label' => 'absence.Absence start',
]); ]);
// @phpstan-ignore-next-line // @phpstan-ignore-next-line

View File

@ -118,7 +118,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface
* Return true if the phonenumber is a landline or voip phone. Return always true * Return true if the phonenumber is a landline or voip phone. Return always true
* if the validation is not configured. * if the validation is not configured.
* *
* @param string $phonenumber * @param string|PhoneNumber $phonenumber
*/ */
public function isValidPhonenumberAny($phonenumber): bool public function isValidPhonenumberAny($phonenumber): bool
{ {
@ -138,7 +138,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface
* Return true if the phonenumber is a landline or voip phone. Return always true * Return true if the phonenumber is a landline or voip phone. Return always true
* if the validation is not configured. * if the validation is not configured.
* *
* @param string $phonenumber * @param string|PhoneNumber $phonenumber
*/ */
public function isValidPhonenumberLandOrVoip($phonenumber): bool public function isValidPhonenumberLandOrVoip($phonenumber): bool
{ {
@ -159,7 +159,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface
* REturn true if the phonenumber is a mobile phone. Return always true * REturn true if the phonenumber is a mobile phone. Return always true
* if the validation is not configured. * if the validation is not configured.
* *
* @param string $phonenumber * @param string|PhoneNumber $phonenumber
*/ */
public function isValidPhonenumberMobile($phonenumber): bool public function isValidPhonenumberMobile($phonenumber): bool
{ {
@ -182,6 +182,10 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface
return null; return null;
} }
if ($phonenumber instanceof PhoneNumber) {
$phonenumber = (string) $phonenumber;
}
// filter only number // filter only number
$filtered = preg_replace('/[^0-9]/', '', $phonenumber); $filtered = preg_replace('/[^0-9]/', '', $phonenumber);

View File

@ -35,6 +35,7 @@ export interface User {
id: number; id: number;
username: string; username: string;
text: string; text: string;
text_without_absence: string;
email: string; email: string;
user_job: Job; user_job: Job;
label: string; label: string;

View File

@ -1,8 +1,7 @@
<template> <template>
<span class="chill-entity entity-user"> <span class="chill-entity entity-user">
{{ user.label }} {{ user.label }}
<span class="user-job" v-if="user.user_job !== null">({{ user.user_job.label.fr }})</span> <span class="user-job" v-if="user.user_job !== null">({{ user.user_job.label.fr }})</span> <span class="main-scope" v-if="user.main_scope !== null">({{ user.main_scope.name.fr }})</span> <span v-if="user.isAbsent" class="badge bg-danger rounded-pill" :title="Absent">A</span>
<span class="main-scope" v-if="user.main_scope !== null">({{ user.main_scope.name.fr }})</span>
</span> </span>
</template> </template>

View File

@ -6,4 +6,7 @@
{%- if opts['main_scope'] and user.mainScope is not null %} {%- if opts['main_scope'] and user.mainScope is not null %}
<span class="main-scope">({{ user.mainScope.name|localize_translatable_string }})</span> <span class="main-scope">({{ user.mainScope.name|localize_translatable_string }})</span>
{%- endif -%} {%- endif -%}
{%- if opts['absence'] and user.isAbsent %}
<span class="badge bg-danger rounded-pill" title="{{ 'absence.Absent'|trans|escape('html_attr') }}">{{ 'absence.A'|trans }}</span>
{%- endif -%}
</span> </span>

View File

@ -1,11 +1,11 @@
<div class="col-10 mt-5"> <div class="col-10 mt-5">
{# vue component #} {# vue component #}
<div id="homepage_widget"></div> <div id="homepage_widget"></div>
{% include '@ChillMain/Homepage/fast_actions.html.twig' %} {% include '@ChillMain/Homepage/fast_actions.html.twig' %}
</div> </div>
{% block css %} {% block css %}
{{ encore_entry_link_tags('page_homepage_widget') }} {{ encore_entry_link_tags('page_homepage_widget') }}
{% endblock %} {% endblock %}

View File

@ -0,0 +1,43 @@
{% extends '@ChillMain/Admin/layout.html.twig' %}
{% block title %}
{{ 'absence.My absence'|trans }}
{% endblock title %}
{% block content %}
<div class="col-md-10">
<h2>{{ 'absence.My absence'|trans }}</h2>
{% if user.absenceStart is not null %}
<div>
<p>{{ 'absence.You are listed as absent, as of'|trans }} {{ user.absenceStart|format_date('long') }}</p>
<ul class="record_actions sticky-form-buttons">
<li>
<a href="{{ path('chill_main_user_absence_unset') }}"
class="btn btn-delete">{{ 'absence.Unset absence'|trans }}</a>
</li>
</ul>
</div>
{% else %}
<div>
<p class="chill-no-data-statement">{{ 'absence.No absence listed'|trans }}</p>
</div>
<div>
{{ form_start(form) }}
{{ form_row(form.absenceStart) }}
<ul class="record_actions sticky-form-buttons">
<li>
<button class="btn btn-save" type="submit">
{{ 'Save'|trans }}
</button>
</li>
</ul>
{{ form_end(form) }}
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -11,6 +11,7 @@
{% block table_entities_thead_tr %} {% block table_entities_thead_tr %}
<th>{{ 'Active'|trans }}</th> <th>{{ 'Active'|trans }}</th>
<th>{{ 'absence.Is absent'|trans }}</th>
<th>{{ 'Username'|trans }}</th> <th>{{ 'Username'|trans }}</th>
<th>{{ 'Datas'|trans }}</th> <th>{{ 'Datas'|trans }}</th>
<th>{{ 'Actions'|trans }}</th> <th>{{ 'Actions'|trans }}</th>
@ -26,6 +27,13 @@
<i class="fa fa-square-o"></i> <i class="fa fa-square-o"></i>
{% endif %} {% endif %}
</td> </td>
<td>
{% if entity.isAbsent %}
<i class="fa fa-check-square-o"></i>
{% else %}
<i class="fa fa-square-o"></i>
{% endif %}
</td>
<td> <td>
{# {#
{% if entity.civility is not null %} {% if entity.civility is not null %}

View File

@ -69,18 +69,26 @@
{% block content %} {% block content %}
<div class="col-8 main_search"> <div class="col-8 main_search">
{% if app.user.isAbsent %}
<div class="d-flex flex-row mb-5 alert alert-warning" role="alert">
<p class="m-2">{{'absence.You are marked as being absent'|trans }}</p>
<span class="ms-auto">
<a class="btn btn-remove" title="Modifier" href="{{ path('chill_main_user_absence_index') }}">{{ 'absence.Unset absence'|trans }}</a>
</span>
</div>
{% endif %}
<h2>{{ 'Search'|trans }}</h2> <h2>{{ 'Search'|trans }}</h2>
<form action="{{ path('chill_main_search') }}" method="get"> <form action="{{ path('chill_main_search') }}" method="get">
<input class="form-control form-control-lg" name="q" type="search" placeholder="{{ 'Search persons, ...'|trans }}" /> <input class="form-control form-control-lg" name="q" type="search" placeholder="{{ 'Search persons, ...'|trans }}" />
<center> <div class="text-center">
<button type="submit" class="btn btn-lg btn-warning mt-3"> <button type="submit" class="btn btn-lg btn-warning mt-3">
<i class="fa fa-fw fa-search"></i> {{ 'Search'|trans }} <i class="fa fa-fw fa-search"></i> {{ 'Search'|trans }}
</button> </button>
<a class="btn btn-lg btn-misc mt-3" href="{{ path('chill_main_advanced_search_list') }}"> <a class="btn btn-lg btn-misc mt-3" href="{{ path('chill_main_advanced_search_list') }}">
<i class="fa fa-fw fa-search"></i> {{ 'Advanced search'|trans }} <i class="fa fa-fw fa-search"></i> {{ 'Advanced search'|trans }}
</a> </a>
</center> </div>
</form> </form>
</div> </div>

View File

@ -78,6 +78,15 @@ class UserMenuBuilder implements LocalMenuBuilderInterface
$nbNotifications = $this->notificationByUserCounter->countUnreadByUser($user); $nbNotifications = $this->notificationByUserCounter->countUnreadByUser($user);
//TODO add an icon? How exactly? For example a clock icon...
$menu
->addChild($this->translator->trans('absence.Set absence date'), [
'route' => 'chill_main_user_absence_index',
])
->setExtras([
'order' => -8888888,
]);
$menu $menu
->addChild( ->addChild(
$this->translator->trans('notification.My notifications with counter', ['nb' => $nbNotifications]), $this->translator->trans('notification.My notifications with counter', ['nb' => $nbNotifications]),

View File

@ -31,6 +31,7 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
'id' => '', 'id' => '',
'username' => '', 'username' => '',
'text' => '', 'text' => '',
'text_without_absent' => '',
'label' => '', 'label' => '',
'email' => '', 'email' => '',
]; ];
@ -82,11 +83,13 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
'id' => $object->getId(), 'id' => $object->getId(),
'username' => $object->getUsername(), 'username' => $object->getUsername(),
'text' => $this->userRender->renderString($object, []), 'text' => $this->userRender->renderString($object, []),
'text_without_absent' => $this->userRender->renderString($object, ['absence' => false]),
'label' => $object->getLabel(), 'label' => $object->getLabel(),
'email' => (string) $object->getEmail(), 'email' => (string) $object->getEmail(),
'user_job' => $this->normalizer->normalize($object->getUserJob(), $format, $userJobContext), 'user_job' => $this->normalizer->normalize($object->getUserJob(), $format, $userJobContext),
'main_center' => $this->normalizer->normalize($object->getMainCenter(), $format, $centerContext), 'main_center' => $this->normalizer->normalize($object->getMainCenter(), $format, $centerContext),
'main_scope' => $this->normalizer->normalize($object->getMainScope(), $format, $scopeContext), 'main_scope' => $this->normalizer->normalize($object->getMainScope(), $format, $scopeContext),
'isAbsent' => $object->isAbsent(),
]; ];
if ('docgen' === $format) { if ('docgen' === $format) {

View File

@ -13,8 +13,10 @@ namespace Chill\MainBundle\Templating\Entity;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Templating\TranslatableStringHelper; use Chill\MainBundle\Templating\TranslatableStringHelper;
use Symfony\Component\Templating\EngineInterface; use DateTimeImmutable;
use Symfony\Component\Templating\EngineInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function array_merge; use function array_merge;
class UserRender implements ChillEntityRenderInterface class UserRender implements ChillEntityRenderInterface
@ -22,16 +24,20 @@ class UserRender implements ChillEntityRenderInterface
public const DEFAULT_OPTIONS = [ public const DEFAULT_OPTIONS = [
'main_scope' => true, 'main_scope' => true,
'user_job' => true, 'user_job' => true,
'absence' => true,
]; ];
private EngineInterface $engine; private EngineInterface $engine;
private TranslatableStringHelper $translatableStringHelper; private TranslatableStringHelper $translatableStringHelper;
public function __construct(TranslatableStringHelper $translatableStringHelper, EngineInterface $engine) private TranslatorInterface $translator;
public function __construct(TranslatableStringHelper $translatableStringHelper, EngineInterface $engine, TranslatorInterface $translator)
{ {
$this->translatableStringHelper = $translatableStringHelper; $this->translatableStringHelper = $translatableStringHelper;
$this->engine = $engine; $this->engine = $engine;
$this->translator = $translator;
} }
public function renderBox($entity, array $options): string public function renderBox($entity, array $options): string
@ -63,6 +69,10 @@ class UserRender implements ChillEntityRenderInterface
->localize($entity->getMainScope()->getName()) . ')'; ->localize($entity->getMainScope()->getName()) . ')';
} }
if ($entity->isAbsent() && $opts['absence']) {
$str .= ' (' . $this->translator->trans('absence.Absent') . ')';
}
return $str; return $str;
} }

View File

@ -138,13 +138,9 @@ services:
autowire: true autowire: true
autoconfigure: true autoconfigure: true
Chill\MainBundle\Form\DataMapper\RegroupmentDataMapper: Chill\MainBundle\Form\AbsenceType: ~
autowire: true Chill\MainBundle\Form\DataMapper\RegroupmentDataMapper: ~
autoconfigure: true Chill\MainBundle\Form\RegroupmentType: ~
Chill\MainBundle\Form\RegroupmentType:
autowire: true
autoconfigure: true
Chill\MainBundle\Form\DataTransformer\IdToLocationDataTransformer: ~ Chill\MainBundle\Form\DataTransformer\IdToLocationDataTransformer: ~
Chill\MainBundle\Form\DataTransformer\IdToUserDataTransformer: ~ Chill\MainBundle\Form\DataTransformer\IdToUserDataTransformer: ~

View File

@ -0,0 +1,37 @@
<?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\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20230111160610 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE users DROP absenceStart');
}
public function getDescription(): string
{
return 'Add absence property to user';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE users ADD absenceStart TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN users.absenceStart IS \'(DC2Type:datetime_immutable)\'');
}
}

View File

@ -229,6 +229,7 @@ Create a new circle: Créer un nouveau cercle
#admin section for location #admin section for location
Location: Localisation Location: Localisation
pick location: Localisation
Location type list: Liste des types de localisation Location type list: Liste des types de localisation
Create a new location type: Créer un nouveau type de localisation Create a new location type: Créer un nouveau type de localisation
Available for users: Disponible aux utilisateurs Available for users: Disponible aux utilisateurs
@ -583,3 +584,17 @@ saved_export:
Export is deleted: L'export est supprimé Export is deleted: L'export est supprimé
Saved export is saved!: L'export est enregistré Saved export is saved!: L'export est enregistré
Created on %date%: Créé le %date% Created on %date%: Créé le %date%
absence:
# single letter for absence
A: A
My absence: Mon absence
Unset absence: Supprimer la date d'absence
Set absence date: Indiquer une date d'absence
Absence start: Absent à partir du
Absent: Absent
You are marked as being absent: Vous êtes indiqué absent.
You are listed as absent, as of: Votre absence est indiquée à partir du
No absence listed: Aucune absence indiquée.
Is absent: Absent?

View File

@ -11,11 +11,13 @@ declare(strict_types=1);
namespace Chill\PersonBundle\DataFixtures\ORM; namespace Chill\PersonBundle\DataFixtures\ORM;
use Chill\MainBundle\DataFixtures\ORM\LoadCenters;
use Chill\MainBundle\DataFixtures\ORM\LoadPostalCodes; use Chill\MainBundle\DataFixtures\ORM\LoadPostalCodes;
use Chill\MainBundle\Entity\Address; use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Entity\PostalCode; use Chill\MainBundle\Entity\PostalCode;
use Chill\PersonBundle\Entity\Household\Household; use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\Person\PersonCenterHistory;
use Chill\PersonBundle\Household\MembersEditorFactory; use Chill\PersonBundle\Household\MembersEditorFactory;
use DateInterval; use DateInterval;
use DateTime; use DateTime;
@ -192,14 +194,20 @@ class LoadHousehold extends Fixture implements DependentFixtureInterface
private function preparePersonIds() private function preparePersonIds()
{ {
$centers = LoadCenters::$centers;
// @TODO: Remove this and make this service stateless // @TODO: Remove this and make this service stateless
$this->personIds = $this->em $this->personIds = $this->em
->createQuery( ->createQuery(
'SELECT p.id FROM ' . Person::class . ' p ' . 'SELECT p.id FROM ' . Person::class . ' p ' .
'JOIN p.center c ' . 'WHERE EXISTS( ' .
'WHERE c.name = :center ' 'SELECT 1 FROM ' . PersonCenterHistory::class . ' pch ' .
'JOIN pch.center c ' .
'WHERE pch.person = p.id ' .
'AND c.name IN (:authorized_centers)' .
')'
) )
->setParameter('center', 'Center A') ->setParameter('authorized_centers', $centers)
->getScalarResult(); ->getScalarResult();
shuffle($this->personIds); shuffle($this->personIds);

View File

@ -680,6 +680,11 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
$this->proxyAccompanyingPeriodOpenState = false; $this->proxyAccompanyingPeriodOpenState = false;
} }
public function countResources(): int
{
return $this->resources->count();
}
/** /**
* This public function is the same but return only true or false. * This public function is the same but return only true or false.
*/ */
@ -764,6 +769,18 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
return $result; return $result;
} }
public function countAccompanyingPeriodInvolved(
bool $asParticipantOpen = true,
bool $asRequestor = true
): int {
// TODO should be optimized to avoid loading accompanying period ?
return $this->getAccompanyingPeriodInvolved($asParticipantOpen, $asRequestor)
->filter(function (AccompanyingPeriod $p) {
return $p->getStep() !== AccompanyingPeriod::STEP_DRAFT;
})
->count();
}
/** /**
* Get AccompanyingPeriodParticipations Collection. * Get AccompanyingPeriodParticipations Collection.
* *

View File

@ -19,7 +19,7 @@ use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use function in_array; use function in_array;
final class JobAggregator implements AggregatorInterface final class UserJobAggregator implements AggregatorInterface
{ {
private UserJobRepository $jobRepository; private UserJobRepository $jobRepository;
@ -40,11 +40,11 @@ final class JobAggregator implements AggregatorInterface
public function alterQuery(QueryBuilder $qb, $data) public function alterQuery(QueryBuilder $qb, $data)
{ {
if (!in_array('acpjob', $qb->getAllAliases(), true)) { if (!in_array('acpuser', $qb->getAllAliases(), true)) {
$qb->leftJoin('acp.job', 'acpjob'); $qb->leftJoin('acp.user', 'acpuser');
} }
$qb->addSelect('IDENTITY(acp.job) AS job_aggregator'); $qb->addSelect('IDENTITY(acpuser.userJob) AS job_aggregator');
$qb->addGroupBy('job_aggregator'); $qb->addGroupBy('job_aggregator');
} }

View File

@ -72,6 +72,7 @@ class HasTemporaryLocationFilter implements FilterInterface
{ {
$builder $builder
->add('having_temporarily', ChoiceType::class, [ ->add('having_temporarily', ChoiceType::class, [
'label' => 'export.filter.course.having_temporarily.label',
'choices' => [ 'choices' => [
'export.filter.course.having_temporarily.Having a temporarily location' => true, 'export.filter.course.having_temporarily.Having a temporarily location' => true,
'export.filter.course.having_temporarily.Having a person\'s location' => false, 'export.filter.course.having_temporarily.Having a person\'s location' => false,

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