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

This commit is contained in:
Julien Fastré 2023-03-06 12:20:20 +01:00
commit f5d17eb38c
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
84 changed files with 1810 additions and 540 deletions

1
.gitignore vendored
View File

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

View File

@ -340,11 +340,6 @@ parameters:
count: 1
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\\.$#"
count: 1

View File

@ -22,6 +22,7 @@ use Chill\DocStoreBundle\Repository\DocumentCategoryRepository;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Templating\Entity\PersonRenderInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
@ -45,6 +46,8 @@ class ActivityContext implements
private PersonRenderInterface $personRender;
private PersonRepository $personRepository;
private TranslatableStringHelperInterface $translatableStringHelper;
private TranslatorInterface $translator;
@ -55,6 +58,7 @@ class ActivityContext implements
TranslatableStringHelperInterface $translatableStringHelper,
EntityManagerInterface $em,
PersonRenderInterface $personRender,
PersonRepository $personRepository,
TranslatorInterface $translator,
BaseContextData $baseContextData
) {
@ -63,6 +67,7 @@ class ActivityContext implements
$this->translatableStringHelper = $translatableStringHelper;
$this->em = $em;
$this->personRender = $personRender;
$this->personRepository = $personRepository;
$this->translator = $translator;
$this->baseContextData = $baseContextData;
}
@ -206,6 +211,32 @@ class ActivityContext implements
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
*/

View File

@ -146,6 +146,16 @@ class ListActivitiesByAccompanyingPeriodContext implements
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
{
$this->accompanyingPeriodContext->storeGenerated($template, $storedObject, $entity, $contextGenerationData);

View File

@ -29,10 +29,10 @@ class CalculatorManager
public function addCalculator(CalculatorInterface $calculator, bool $default)
{
$this->calculators[$calculator::getAlias()] = $calculator;
$this->calculators[$calculator->getAlias()] = $calculator;
if ($default) {
$this->defaultCalculator[] = $calculator::getAlias();
$this->defaultCalculator[] = $calculator->getAlias();
}
}
@ -50,7 +50,7 @@ class CalculatorManager
$result = $calculator->calculate($elements);
if (null !== $result) {
$results[$calculator::getAlias()] = $result;
$results[$calculator->getAlias()] = $result;
}
}

View File

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

View File

@ -12,6 +12,8 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\DataFixtures\ORM;
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\FixtureGroupInterface;
use Doctrine\Persistence\ObjectManager;
@ -33,14 +35,21 @@ class LoadInvite extends Fixture implements FixtureGroupInterface
public function load(ObjectManager $manager): void
{
$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) {
echo 'Creating calendar invite : ' . $a['name']['fr'] . "\n";
$invite = (new Invite())
->setStatus($a['name']);
->setStatus($a['status'])
->setUser($this->getRandomUser());
$manager->persist($invite);
$reference = 'Invite_' . $a['name']['fr'];
$this->addReference($reference, $invite);
@ -49,4 +58,11 @@ class LoadInvite extends Fixture implements FixtureGroupInterface
$manager->flush();
}
private function getRandomUser(): User
{
$userRef = array_rand(LoadUsers::$refs);
return $this->getReference($userRef);
}
}

View File

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

View File

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

View File

@ -18,8 +18,10 @@ use Chill\DocGeneratorBundle\Service\Context\BaseContextData;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Templating\Entity\PersonRender;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Chill\ThirdPartyBundle\Repository\ThirdPartyRepository;
use Chill\ThirdPartyBundle\Templating\Entity\ThirdPartyRender;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
@ -39,6 +41,10 @@ final class CalendarContext implements CalendarContextInterface
private PersonRender $personRender;
private PersonRepository $personRepository;
private ThirdPartyRepository $thirdPartyRepository;
private ThirdPartyRender $thirdPartyRender;
private TranslatableStringHelperInterface $translatableStringHelper;
@ -48,14 +54,18 @@ final class CalendarContext implements CalendarContextInterface
EntityManagerInterface $entityManager,
NormalizerInterface $normalizer,
PersonRender $personRender,
PersonRepository $personRepository,
ThirdPartyRender $thirdPartyRender,
ThirdPartyRepository $thirdPartyRepository,
TranslatableStringHelperInterface $translatableStringHelper
) {
$this->baseContextData = $baseContextData;
$this->entityManager = $entityManager;
$this->normalizer = $normalizer;
$this->personRender = $personRender;
$this->personRepository = $personRepository;
$this->thirdPartyRender = $thirdPartyRender;
$this->thirdPartyRepository = $thirdPartyRepository;
$this->translatableStringHelper = $translatableStringHelper;
}
@ -226,8 +236,44 @@ final class CalendarContext implements CalendarContextInterface
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
{

View File

@ -56,6 +56,10 @@ interface CalendarContextInterface extends DocGeneratorContextWithPublicFormInte
*/
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
*/

View File

@ -23,6 +23,9 @@ interface DocGeneratorContextWithPublicFormInterface extends DocGeneratorContext
*/
public function buildPublicForm(FormBuilderInterface $builder, DocGeneratorTemplate $template, $entity): void;
/**
* Fill the form with initial data
*/
public function getFormData(DocGeneratorTemplate $template, $entity): array;
/**
@ -31,4 +34,14 @@ interface DocGeneratorContextWithPublicFormInterface extends DocGeneratorContext
* @param mixed $entity
*/
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,68 +16,58 @@ use Chill\DocGeneratorBundle\Context\DocGeneratorContextWithPublicFormInterface;
use Chill\DocGeneratorBundle\Context\Exception\ContextNotFoundException;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocGeneratorBundle\GeneratorDriver\DriverInterface;
use Chill\DocGeneratorBundle\GeneratorDriver\Exception\TemplateException;
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\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Serializer\Model\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
// TODO à mettre dans services
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
use function strlen;
use const JSON_PRETTY_PRINT;
final class DocGeneratorTemplateController extends AbstractController
{
private HttpClientInterface $client;
private ContextManager $contextManager;
private DocGeneratorTemplateRepository $docGeneratorTemplateRepository;
private DriverInterface $driver;
private EntityManagerInterface $entityManager;
private LoggerInterface $logger;
private GeneratorInterface $generator;
private MessageBusInterface $messageBus;
private PaginatorFactory $paginatorFactory;
private StoredObjectManagerInterface $storedObjectManager;
public function __construct(
ContextManager $contextManager,
DocGeneratorTemplateRepository $docGeneratorTemplateRepository,
DriverInterface $driver,
LoggerInterface $logger,
GeneratorInterface $generator,
MessageBusInterface $messageBus,
PaginatorFactory $paginatorFactory,
HttpClientInterface $client,
StoredObjectManagerInterface $storedObjectManager,
EntityManagerInterface $entityManager
) {
$this->contextManager = $contextManager;
$this->docGeneratorTemplateRepository = $docGeneratorTemplateRepository;
$this->driver = $driver;
$this->logger = $logger;
$this->generator = $generator;
$this->messageBus = $messageBus;
$this->paginatorFactory = $paginatorFactory;
$this->client = $client;
$this->storedObjectManager = $storedObjectManager;
$this->entityManager = $entityManager;
}
@ -95,7 +85,6 @@ final class DocGeneratorTemplateController extends AbstractController
): Response {
return $this->generateDocFromTemplate(
$template,
$entityClassName,
$entityId,
$request,
true
@ -116,7 +105,6 @@ final class DocGeneratorTemplateController extends AbstractController
): Response {
return $this->generateDocFromTemplate(
$template,
$entityClassName,
$entityId,
$request,
false
@ -186,7 +174,6 @@ final class DocGeneratorTemplateController extends AbstractController
private function generateDocFromTemplate(
DocGeneratorTemplate $template,
string $entityClassName,
int $entityId,
Request $request,
bool $isTest
@ -207,7 +194,7 @@ final class DocGeneratorTemplateController extends AbstractController
if (null === $entity) {
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)
);
}
@ -260,99 +247,68 @@ final class DocGeneratorTemplateController extends AbstractController
}
}
$document = $template->getFile();
if ($isTest && ($contextGenerationData['test_file'] instanceof File)) {
$dataDecrypted = file_get_contents($contextGenerationData['test_file']->getPathname());
} else {
try {
$dataDecrypted = $this->storedObjectManager->read($document);
} catch (Throwable $exception) {
throw $exception;
}
}
// transform context generation data
$contextGenerationDataSanitized =
$context instanceof DocGeneratorContextWithPublicFormInterface ?
$context->contextGenerationDataNormalize($template, $entity, $contextGenerationData)
: [];
// if is test, render the data or generate the doc
if ($isTest && isset($form) && $form['show_data']->getData()) {
return $this->render('@ChillDocGenerator/Generator/debug_value.html.twig', [
'datas' => json_encode($context->getData($template, $entity, $contextGenerationData), JSON_PRETTY_PRINT),
]);
}
try {
$generatedResource = $this
->driver
->generateFromString(
$dataDecrypted,
$template->getFile()->getType(),
$context->getData($template, $entity, $contextGenerationData),
$template->getFile()->getFilename()
);
} catch (TemplateException $e) {
return new Response(
implode("\n", $e->getErrors()),
400,
[
'Content-Type' => 'text/plain',
]
} elseif ($isTest) {
$generated = $this->generator->generateDocFromTemplate(
$template,
$entityId,
$contextGenerationDataSanitized,
null,
true,
isset($form) ? $form['test_file']->getData() : null
);
}
if ($isTest) {
return new Response(
$generatedResource,
$generated,
Response::HTTP_OK,
[
'Content-Transfer-Encoding', 'binary',
'Content-Type' => 'application/vnd.oasis.opendocument.text',
'Content-Disposition' => 'attachment; filename="generated.odt"',
'Content-Length' => strlen($generatedResource),
'Content-Length' => strlen($generated),
],
);
}
/** @var StoredObject $storedObject */
$storedObject = (new ObjectNormalizer())
->denormalize(
[
'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 is not a test
// we prepare the object to store the document
$storedObject = (new StoredObject())
->setStatus(StoredObject::STATUS_PENDING)
;
$this->entityManager->persist($storedObject);
try {
$context
->storeGenerated(
$template,
$storedObject,
$entity,
$contextGenerationData
);
} catch (Exception $e) {
$this
->logger
->error(
'Unable to store the associated document to entity',
[
'entityClassName' => $entityClassName,
'entityId' => $entityId,
'contextKey' => $context->getName(),
]
);
throw $e;
}
// we store the generated document
$context
->storeGenerated(
$template,
$storedObject,
$entity,
$contextGenerationData
);
$this->entityManager->flush();
$this->messageBus->dispatch(
new RequestGenerationMessage(
$this->getUser(),
$template,
$entityId,
$storedObject,
$contextGenerationDataSanitized,
)
);
return $this
->redirectToRoute(
'chill_wopi_file_edit',

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

@ -1,29 +1,19 @@
<?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\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 Exception;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\File\File;
use Throwable;
class Generator
class Generator implements GeneratorInterface
{
private ContextManagerInterface $contextManager;
@ -35,6 +25,8 @@ class Generator
private StoredObjectManagerInterface $storedObjectManager;
private const LOG_PREFIX = '[docgen generator] ';
public function __construct(
ContextManagerInterface $contextManager,
DriverInterface $driver,
@ -52,49 +44,69 @@ class Generator
/**
* @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
* @throws \Symfony\Component\Serializer\Exception\ExceptionInterface|\Throwable
*/
public function generateDocFromTemplate(
DocGeneratorTemplate $template,
string $entityClassName,
int $entityId,
?StoredObject $destinationStoredObject = null,
bool $isTest = false,
?File $testFile = null
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);
$contextGenerationData = ['test_file' => $testFile];
$entity = $this
->entityManager
->find($context->getEntityClass(), $entityId);
->find($context->getEntityClass(), $entityId)
;
if (null === $entity) {
throw new RelatedEntityNotFoundException($entityClassName, $entityId);
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)) {
$dataDecrypted = file_get_contents($testFile->getPathname());
$templateDecrypted = file_get_contents($testFile->getPathname());
} else {
$dataDecrypted = $this->storedObjectManager->read($template->getFile());
$templateDecrypted = $this->storedObjectManager->read($template->getFile());
}
try {
$generatedResource = $this
->driver
->generateFromString(
$dataDecrypted,
$templateDecrypted,
$template->getFile()->getType(),
$context->getData($template, $entity, $contextGenerationData),
$data,
$template->getFile()->getFilename()
);
} catch (TemplateException $e) {
@ -102,6 +114,11 @@ class Generator
}
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;
}
@ -109,35 +126,18 @@ class Generator
$destinationStoredObject
->setType($template->getFile()->getType())
->setFilename(sprintf('%s_odt', uniqid('doc_', true)))
->setStatus(StoredObject::STATUS_READY);
->setStatus(StoredObject::STATUS_READY)
;
$this->storedObjectManager->write($destinationStoredObject, $generatedResource);
try {
$context
->storeGenerated(
$template,
$destinationStoredObject,
$entity,
$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->logger->info(self::LOG_PREFIX.'Finished generation of a document', [
'entity_id' => $entityId,
'destination_stored_object' => $destinationStoredObject->getId(),
]);
return null;
}
}

View File

@ -30,4 +30,12 @@ class GeneratorException extends RuntimeException
$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,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

@ -1,35 +1,35 @@
<?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\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 $creatorId;
private string $entityClassName;
private int $templateId;
private int $entityId;
private int $entityId;
private int $templateId;
private int $destinationStoredObjectId;
public function __construct(User $creator, DocGeneratorTemplate $template, int $entityId, string $entityClassName)
{
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->entityClassName = $entityClassName;
$this->destinationStoredObjectId = $destinationStoredObject->getId();
$this->contextGenerationData = $contextGenerationData;
}
public function getCreatorId(): int
@ -37,9 +37,14 @@ class RequestGenerationMessage
return $this->creatorId;
}
public function getEntityClassName(): string
public function getDestinationStoredObjectId(): int
{
return $this->entityClassName;
return $this->destinationStoredObjectId;
}
public function getTemplateId(): int
{
return $this->templateId;
}
public function getEntityId(): int
@ -47,8 +52,8 @@ class RequestGenerationMessage
return $this->entityId;
}
public function getTemplateId(): int
public function getContextGenerationData(): array
{
return $this->templateId;
return $this->contextGenerationData;
}
}

View File

@ -20,10 +20,14 @@ services:
resource: '../Serializer/Normalizer/'
tags:
- { name: 'serializer.normalizer', priority: -152 }
Chill\DocGeneratorBundle\Serializer\Normalizer\CollectionDocGenNormalizer:
tags:
- { name: 'serializer.normalizer', priority: -126 }
Chill\DocGeneratorBundle\Service\Context\:
resource: "../Service/Context"
Chill\DocGeneratorBundle\Controller\:
resource: "../Controller"
autowire: true
@ -34,18 +38,20 @@ services:
autowire: true
autoconfigure: true
Chill\DocGeneratorBundle\Service\Context\:
resource: "../Service/Context/"
autowire: true
autoconfigure: true
Chill\DocGeneratorBundle\GeneratorDriver\:
resource: "../GeneratorDriver/"
autowire: 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\Context\ContextManager:
arguments:
$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

@ -1,14 +1,5 @@
<?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\DocGeneratorBundle\tests\Service\Context\Generator;
use Chill\DocGeneratorBundle\Context\ContextManagerInterface;
@ -26,14 +17,64 @@ use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
/**
* @internal
* @coversNothing
*/
final class GeneratorTest extends TestCase
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);
@ -52,8 +93,8 @@ final class GeneratorTest extends TestCase
$generator->generateDocFromTemplate(
$template,
'DummyEntity',
1,
[],
$destinationStoredObject
);
}
@ -65,6 +106,9 @@ final class GeneratorTest extends TestCase
$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');
@ -89,58 +133,8 @@ final class GeneratorTest extends TestCase
$generator->generateDocFromTemplate(
$template,
'DummyEntity',
1,
$destinationStoredObject
);
}
public function testSuccessfulGeneration(): void
{
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject())
->setType('application/test'));
$destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_PENDING);
$entity = new class() {
};
$data = [];
$context = $this->prophesize(DocGeneratorContextInterface::class);
$context->getData($template, $entity, Argument::type('array'))->willReturn($data);
$context->storeGenerated($template, $destinationStoredObject, $entity, Argument::type('array'))
->shouldBeCalled();
$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(Argument::type('string'), Argument::type('int'))
->willReturn($entity);
$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,
'DummyEntity',
1,
[],
$destinationStoredObject
);
}

View File

@ -10,6 +10,16 @@ docgen:
test generate: Tester la génération
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:
docgen_template:
@ -19,4 +29,4 @@ crud:
Show data instead of generating: Montrer les données au lieu de générer le document
Template file: Fichier modèle
Template file: Fichier modèle

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

@ -18,7 +18,6 @@ use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
@ -36,20 +35,12 @@ use Symfony\Component\Serializer\Annotation as Serializer;
*/
class StoredObject implements AsyncFileInterface, Document, TrackCreationInterface
{
public const STATUS_READY = "ready";
public const STATUS_PENDING = "pending";
public const STATUS_FAILURE = "failure";
use TrackCreationTrait;
public const STATUS_FAILURE = 'failure';
public const STATUS_PENDING = 'pending';
public const STATUS_READY = 'ready';
/**
* @ORM\Column(type="datetime", name="creation_date")
* @Serializer\Groups({"read", "write"})
*/
private DateTimeInterface $creationDate;
/**
* @ORM\Column(type="json", name="datas")
* @Serializer\Groups({"read", "write"})
@ -83,16 +74,6 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
*/
private array $keyInfos = [];
/**
* @ORM\Column(type="text", options={"default": "ready"})
*/
private string $status;
/**
* @ORM\ManyToOne(targetEntity=DocGeneratorTemplate::class)
*/
private ?DocGeneratorTemplate $template;
/**
* @ORM\Column(type="text", name="title")
* @Serializer\Groups({"read", "write"})
@ -100,7 +81,7 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
private string $title = '';
/**
* @ORM\Column(type="text", name="type")
* @ORM\Column(type="text", name="type", options={"default": ""})
* @Serializer\Groups({"read", "write"})
*/
private string $type = '';
@ -111,17 +92,46 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
*/
private UuidInterface $uuid;
/**
* @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')
public function __construct(string $status = "ready")
{
$this->uuid = Uuid::uuid4();
$this->status = $status;
}
public function addGenerationTrial(): self
{
$this->generationTrialsCounter++;
return $this;
}
/**
* @Serializer\Groups({"read", "write"})
* @deprecated
*/
public function getCreationDate(): DateTime
{
@ -138,6 +148,11 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
return $this->filename;
}
public function getGenerationTrialsCounter(): int
{
return $this->generationTrialsCounter;
}
public function getId(): ?int
{
return $this->id;
@ -161,6 +176,9 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
return $this->getFilename();
}
/**
* @return StoredObject::STATUS_*
*/
public function getStatus(): string
{
return $this->status;
@ -188,10 +206,11 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
/**
* @Serializer\Groups({"write"})
* @deprecated
*/
public function setCreationDate(DateTime $creationDate): self
{
$this->createdAt = DateTimeImmutable::createFromMutable($creationDate);
$this->createdAt = \DateTimeImmutable::createFromMutable($creationDate);
return $this;
}
@ -247,4 +266,30 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
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 DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.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({});
@ -15,19 +16,32 @@ window.addEventListener('DOMContentLoaded', function (e) {
filename: string,
canEdit: string,
storedObject: string,
small: string,
buttonSmall: string,
};
const
storedObject = JSON.parse(datasets.storedObject),
storedObject = JSON.parse(datasets.storedObject) as StoredObject,
filename = datasets.filename,
canEdit = datasets.canEdit === '1',
small = datasets.small === '1'
small = datasets.buttonSmall === '1'
;
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);

View File

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

View File

@ -1,6 +1,6 @@
<template>
<div 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">
<div v-if="'ready' === props.storedObject.status" class="dropdown">
<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
</button>
<ul class="dropdown-menu">
@ -15,16 +15,27 @@
</li>
</ul>
</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>
<script lang="ts" setup>
import {onMounted} from "vue";
import ConvertButton from "./StoredObjectButton/ConvertButton.vue";
import DownloadButton from "./StoredObjectButton/DownloadButton.vue";
import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue";
import {is_extension_editable, is_extension_viewable} from "./StoredObjectButton/helpers";
import {StoredObject, WopiEditButtonExecutableBeforeLeaveFunction} from "../types";
import {is_extension_editable, is_extension_viewable, is_object_ready} from "./StoredObjectButton/helpers";
import {
StoredObject,
StoredObjectStatusChange,
WopiEditButtonExecutableBeforeLeaveFunction
} from "../types";
interface DocumentActionButtonsGroupConfig {
storedObject: StoredObject,
@ -48,6 +59,10 @@ interface DocumentActionButtonsGroupConfig {
executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction,
}
const emit = defineEmits<{
(e: 'onStoredObjectStatusChange', newStatus: StoredObjectStatusChange): void
}>();
const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), {
small: false,
canEdit: true,
@ -56,6 +71,51 @@ const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), {
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>
<style scoped>

View File

@ -1,3 +1,4 @@
import {StoredObject, StoredObjectStatus, StoredObjectStatusChange} from "../../types";
const MIMES_EDIT = new Set([
'application/vnd.ms-powerpoint',
@ -168,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 {
build_convert_link,
build_download_info_link,
@ -176,4 +189,5 @@ export {
download_doc,
is_extension_editable,
is_extension_viewable,
is_object_ready,
};

View File

@ -9,11 +9,6 @@
<dt>{{ 'Title'|trans }}</dt>
<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>
<dd>{{ document.category.name|localize_translatable_string }}</dd>

View File

@ -5,18 +5,25 @@
<div class="item-bloc">
<div class="item-row">
<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">
{{ document.title }}
</div>
<div>
{{ mm.mimeIcon(document.object.type) }}
</div>
{% if document.object.type is not empty %}
<div>
{{ mm.mimeIcon(document.object.type) }}
</div>
{% endif %}
<div>
<p>{{ document.category.name|localize_translatable_string }}</p>
</div>
{% if document.template is not null %}
{% if document.object.hasTemplate %}
<div>
<p>{{ document.template.name.fr }}</p>
<p>{{ document.object.template.name|localize_translatable_string }}</p>
</div>
{% endif %}
</div>

View File

@ -157,7 +157,7 @@ final class WopiEditTwigExtensionRuntime implements RuntimeExtensionInterface
'document_json' => $this->normalizer->normalize($document, 'json', [AbstractNormalizer::GROUPS => ['read']]),
'title' => $title,
'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

@ -72,7 +72,6 @@ class ChillMainBundle extends Bundle
$container->addCompilerPass(new NotificationCounterCompilerPass());
$container->addCompilerPass(new MenuCompilerPass());
$container->addCompilerPass(new ACLFlagsCompilerPass());
$container->addCompilerPass(new GroupingCenterCompilerPass());
$container->addCompilerPass(new CRUDControllerCompilerPass());
$container->addCompilerPass(new ShortMessageCompilerPass());
}

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

@ -298,6 +298,8 @@ class ExportController extends AbstractController
'csrf_protection' => $isGenerate ? false : true,
]);
// TODO: add a condition to be able to select a regroupment of centers?
if ('centers' === $step || 'generate_centers' === $step) {
$builder->add('centers', PickCenterType::class, [
'export_alias' => $alias,

View File

@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\DependencyInjection\CompilerPass;
use LogicException;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
class GroupingCenterCompilerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container)
{
if (false === $container->hasDefinition('chill.main.form.pick_centers_type')) {
throw new LogicException('The service chill.main.form.pick_centers_type does '
. 'not exists in container');
}
$pickCenterType = $container->getDefinition('chill.main.form.pick_centers_type');
foreach ($container->findTaggedServiceIds('chill.grouping_center') as $serviceId => $tagged) {
$pickCenterType->addMethodCall(
'addGroupingCenter',
[new Reference($serviceId)]
);
}
}
}

View File

@ -24,7 +24,7 @@ class Regroupment
/**
* @var Center
* @ORM\ManyToMany(
* targetEntity="Chill\MainBundle\Entity\Center"
* targetEntity=Center::class
* )
* @ORM\Id
*/
@ -43,7 +43,7 @@ class Regroupment
private bool $isActive = true;
/**
* @ORM\Column(type="string", length=15, options={"default": ""}, nullable=false)
* @ORM\Column(type="text", options={"default": ""}, nullable=false)
*/
private string $name = '';
@ -52,7 +52,7 @@ class Regroupment
$this->centers = new ArrayCollection();
}
public function getCenters(): ?Collection
public function getCenters(): Collection
{
return $this->centers;
}

View File

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

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

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form\DataMapper;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Regroupment;
use Chill\MainBundle\Repository\RegroupmentRepository;
use Exception;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\FormInterface;
use function count;
class ExportPickCenterDataMapper implements DataMapperInterface
{
protected RegroupmentRepository $regroupmentRepository;
/**
* @param array|Center[] $data
* @param $forms
*
* @throws Exception
*
* @return mixed
*/
public function mapDataToForms($data, $forms)
{
if (null === $data) {
return;
}
/** @var array<string, FormInterface> $form */
$form = iterator_to_array($forms);
$pickedRegroupment = [];
foreach ($this->regroupmentRepository->findAll() as $regroupment) {
[$contained, $notContained] = $regroupment->getCenters()->partition(static function (Center $center) {
});
if (0 === count($notContained)) {
$pickedRegroupment[] = $regroupment;
}
}
$form['regroupment']->setData($pickedRegroupment);
$form['centers']->setData($data);
}
/**
* @param iterable $forms
* @param array $data
*
* @return void
*/
public function mapFormsToData($forms, &$data)
{
/** @var array<string, FormInterface> $forms */
$forms = iterator_to_array($forms);
$centers = [];
foreach ($forms['center']->getData() as $center) {
$centers[spl_object_hash($center)] = $center;
}
foreach ($forms['regroupment']->getData() as $regroupment) {
/** @var Regroupment $regroupment */
foreach ($regroupment->getCenters() as $center) {
$centers[spl_object_hash($center)] = $center;
}
}
$data = array_values($centers);
}
}

View File

@ -11,57 +11,46 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form\Type\Export;
use Chill\MainBundle\Center\GroupingCenterInterface;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Regroupment;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Form\DataMapper\ExportPickCenterDataMapper;
use Chill\MainBundle\Repository\RegroupmentRepository;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use function array_intersect;
use function array_key_exists;
use function array_merge;
use function array_unique;
use function count;
use function in_array;
/**
* Pick centers amongst available centers for the user.
*/
class PickCenterType extends AbstractType
final class PickCenterType extends AbstractType
{
public const CENTERS_IDENTIFIERS = 'c';
protected AuthorizationHelperInterface $authorizationHelper;
private AuthorizationHelperInterface $authorizationHelper;
protected ExportManager $exportManager;
private ExportManager $exportManager;
/**
* @var array|GroupingCenterInterface[]
*/
protected array $groupingCenters = [];
private RegroupmentRepository $regroupmentRepository;
protected UserInterface $user;
private UserInterface $user;
public function __construct(
TokenStorageInterface $tokenStorage,
ExportManager $exportManager,
RegroupmentRepository $regroupmentRepository,
AuthorizationHelperInterface $authorizationHelper
) {
$this->exportManager = $exportManager;
$this->user = $tokenStorage->getToken()->getUser();
$this->authorizationHelper = $authorizationHelper;
}
public function addGroupingCenter(GroupingCenterInterface $grouping)
{
$this->groupingCenters[md5($grouping->getName())] = $grouping;
$this->regroupmentRepository = $regroupmentRepository;
}
public function buildForm(FormBuilderInterface $builder, array $options)
@ -72,97 +61,36 @@ class PickCenterType extends AbstractType
$export->requiredRole()
);
$builder->add(self::CENTERS_IDENTIFIERS, EntityType::class, [
$builder->add('center', EntityType::class, [
'class' => Center::class,
'label' => 'center',
'choices' => $centers,
'multiple' => true,
'expanded' => true,
'choice_label' => static function (Center $c) {
return $c->getName();
},
'data' => count($this->groupingCenters) > 0 ? null : $centers,
'data' => $centers,
]);
if (count($this->groupingCenters) > 0) {
$groupingBuilder = $builder->create('g', null, [
'compound' => true,
if (count($this->regroupmentRepository->findAllActive()) > 0) {
$builder->add('regroupment', EntityType::class, [
'class' => Regroupment::class,
'label' => 'regroupment',
'multiple' => true,
'expanded' => true,
'choices' => $this->regroupmentRepository->findAllActive(),
'choice_label' => static function (Regroupment $r) {
return $r->getName();
},
]);
foreach ($this->groupingCenters as $key => $gc) {
$choices = $this->buildChoices($centers, $gc);
if (count($choices) > 0) {
$groupingBuilder->add($key, ChoiceType::class, [
'choices' => $choices,
'multiple' => true,
'expanded' => true,
'label' => $gc->getName(),
'required' => false,
]);
}
}
if ($groupingBuilder->count() > 0) {
$builder->add($groupingBuilder);
}
}
$builder->addModelTransformer(new CallbackTransformer(
function ($data) use ($centers) {
return $this->transform($data, $centers);
},
function ($data) use ($centers) {
return $this->reverseTransform($data, $centers);
}
));
$builder->setDataMapper(new ExportPickCenterDataMapper());
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setRequired('export_alias');
}
protected function buildChoices($reachablesCenters, GroupingCenterInterface $gc)
{
$result = [];
foreach ($gc->getGroups() as $group) {
foreach ($gc->getCentersForGroup($group) as $center) {
if (in_array($center, $reachablesCenters, true)) {
$result[$group] = $group;
}
}
}
return $result;
}
protected function reverseTransform($data, $centers)
{
$picked = $data[self::CENTERS_IDENTIFIERS]
instanceof \Doctrine\Common\Collections\Collection ?
$data[self::CENTERS_IDENTIFIERS]->toArray()
:
$data[self::CENTERS_IDENTIFIERS];
if (array_key_exists('g', $data)) {
foreach ($data['g'] as $gcid => $group) {
$picked =
array_merge(
array_intersect(
$this->groupingCenters[$gcid]->getCentersForGroup($group),
$centers
),
$picked
);
}
}
return array_unique($picked);
}
protected function transform($data, $centers)
{
return $data;
}
}

View File

@ -15,6 +15,7 @@ use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\PickCivilityType;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Doctrine\ORM\EntityRepository;
@ -110,6 +111,11 @@ class UserType extends AbstractType
return $qb;
},
])
->add('absenceStart', ChillDateType::class, [
'required' => false,
'input' => 'datetime_immutable',
'label' => 'absence.Absence start',
]);
// @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
* if the validation is not configured.
*
* @param string $phonenumber
* @param string|PhoneNumber $phonenumber
*/
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
* if the validation is not configured.
*
* @param string $phonenumber
* @param string|PhoneNumber $phonenumber
*/
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
* if the validation is not configured.
*
* @param string $phonenumber
* @param string|PhoneNumber $phonenumber
*/
public function isValidPhonenumberMobile($phonenumber): bool
{
@ -182,6 +182,10 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface
return null;
}
if ($phonenumber instanceof PhoneNumber) {
$phonenumber = (string) $phonenumber;
}
// filter only number
$filtered = preg_replace('/[^0-9]/', '', $phonenumber);

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\Regroupment;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository;
final class RegroupmentRepository implements ObjectRepository
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
$this->repository = $entityManager->getRepository(Regroupment::class);
}
public function find($id, $lockMode = null, $lockVersion = null): ?Regroupment
{
return $this->repository->find($id, $lockMode, $lockVersion);
}
/**
* @return Regroupment[]
*/
public function findAll(): array
{
return $this->repository->findAll();
}
public function findAllActive(): array
{
return $this->repository->findBy(['isActive' => true], ['name' => 'ASC']);
}
/**
* @param mixed|null $limit
* @param mixed|null $offset
*
* @return Regroupment[]
*/
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria, ?array $orderBy = null): ?Regroupment
{
return $this->repository->findOneBy($criteria, $orderBy);
}
public function getClassName()
{
return Regroupment::class;
}
}

View File

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

View File

@ -1,8 +1,7 @@
<template>
<span class="chill-entity entity-user">
{{ user.label }}
<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 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>
</template>

View File

@ -6,4 +6,7 @@
{%- if opts['main_scope'] and user.mainScope is not null %}
<span class="main-scope">({{ user.mainScope.name|localize_translatable_string }})</span>
{%- 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>

View File

@ -1,5 +1,5 @@
{#
* Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS,
* Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS,
<info@champs-libres.coop> / <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
@ -22,39 +22,34 @@
{% block content %}
<div class="col-md-10">
{{ include('@ChillMain/Export/_breadcrumb.html.twig') }}
<h1>{{ export.title|trans }}</h1>
<p>{{ export.description|trans }}</p>
{{ form_start(form) }}
<section class="center mb-4">
<h2>{{ 'Pick centers'|trans }}</h2>
<p>{{ 'The export will contains only data from the picked centers.'|trans }}
{{ 'This will eventually restrict your possibilities in filtering the data.'|trans }}</p>
{{ form_widget(form.centers.c) }}
{% if form.centers.children.g is defined %}
<h3>{{ 'Pick aggregated centers'|trans }}</h3>
{% for f in form.centers.children.g.children %}
{{ form_row(f) }}
{% endfor %}
<h3 class="m-3">{{ 'Center'|trans }}</h3>
{{ form_widget(form.centers.center) }}
{% if form.centers.regroupment is defined %}
<h3 class="m-3">{{ 'Pick aggregated centers'|trans }}</h3>
{{ form_widget(form.centers.regroupment) }}
{% endif %}
</section>
<p>{{ form_widget(form.submit, { 'attr' : { 'class' : 'btn btn-action btn-create' }, 'label' : 'Go to export options' } ) }}</p>
{{ form_end(form) }}
</div>
{% endblock content %}

View File

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

@ -2,20 +2,21 @@
{% block admin_content %}
{% embed '@ChillMain/CRUD/_index.html.twig' %}
{% block index_header %}
<h1>{{"Users"|trans}}</h1>
{% endblock %}
{% block filter_order %}{{ filter_order|chill_render_filter_order_helper }}{% endblock %}
{% block table_entities_thead_tr %}
<th>{{ 'Active'|trans }}</th>
<th>{{ 'absence.Is absent'|trans }}</th>
<th>{{ 'Username'|trans }}</th>
<th>{{ 'Datas'|trans }}</th>
<th>{{ 'Actions'|trans }}</th>
{% endblock %}
{% block table_entities_tbody %}
{% for entity in entities %}
<tr>
@ -26,6 +27,13 @@
<i class="fa fa-square-o"></i>
{% endif %}
</td>
<td>
{% if entity.isAbsent %}
<i class="fa fa-check-square-o"></i>
{% else %}
<i class="fa fa-square-o"></i>
{% endif %}
</td>
<td>
{#
{% if entity.civility is not null %}
@ -64,13 +72,13 @@
<li>
<a class="btn btn-edit" title="{{ 'Edit'|trans }}" href="{{ path('chill_crud_admin_user_edit', { 'id': entity.id }) }}"></a>
</li>
{% if allow_change_password is same as(true) %}
<li>
<a class="btn btn-chill-red" href="{{ path('admin_user_edit_password', { 'id' : entity.id }) }}" title="{{ 'Edit password'|trans|e('html_attr') }}"><i class="fa fa-ellipsis-h"></i></a>
</li>
{% endif %}
{% if is_granted('ROLE_ALLOWED_TO_SWITCH') %}
<li>
<a class="btn btn-chill-blue" href="{{ path('chill_main_homepage', {'_switch_user': entity.username }) }}" title="{{ "Impersonate"|trans|e('html_attr') }}"><i class="fa fa-user-secret"></i></a>
@ -81,9 +89,9 @@
</tr>
{% endfor %}
{% endblock %}
{% block pagination %}{{ chill_pagination(paginator) }}{% endblock %}
{% block list_actions %}
<ul class="record_actions sticky-form-buttons">
<li class='cancel'>
@ -94,6 +102,6 @@
</li>
</ul>
{% endblock list_actions %}
{% endembed %}
{% endblock %}

View File

@ -69,18 +69,26 @@
{% block content %}
<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>
<form action="{{ path('chill_main_search') }}" method="get">
<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">
<i class="fa fa-fw fa-search"></i> {{ 'Search'|trans }}
</button>
<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 }}
</a>
</center>
</div>
</form>
</div>

View File

@ -78,6 +78,15 @@ class UserMenuBuilder implements LocalMenuBuilderInterface
$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
->addChild(
$this->translator->trans('notification.My notifications with counter', ['nb' => $nbNotifications]),

View File

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

View File

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

View File

@ -138,9 +138,9 @@ services:
autowire: true
autoconfigure: true
Chill\MainBundle\Form\RegroupmentType:
autowire: true
autoconfigure: true
Chill\MainBundle\Form\AbsenceType: ~
Chill\MainBundle\Form\DataMapper\RegroupmentDataMapper: ~
Chill\MainBundle\Form\RegroupmentType: ~
Chill\MainBundle\Form\DataTransformer\IdToLocationDataTransformer: ~
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

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20230301155213 extends AbstractMigration
{
public function getDescription(): string
{
return 'Alter type to TEXT for regroupment.name';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE regroupment ALTER name TYPE TEXT');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE regroupment ALTER name TYPE VARCHAR(15)');
}
}

View File

@ -584,3 +584,17 @@ saved_export:
Export is deleted: L'export est supprimé
Saved export is saved!: L'export est enregistré
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;
use Chill\MainBundle\DataFixtures\ORM\LoadCenters;
use Chill\MainBundle\DataFixtures\ORM\LoadPostalCodes;
use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Entity\PostalCode;
use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\Person\PersonCenterHistory;
use Chill\PersonBundle\Household\MembersEditorFactory;
use DateInterval;
use DateTime;
@ -192,14 +194,20 @@ class LoadHousehold extends Fixture implements DependentFixtureInterface
private function preparePersonIds()
{
$centers = LoadCenters::$centers;
// @TODO: Remove this and make this service stateless
$this->personIds = $this->em
->createQuery(
'SELECT p.id FROM ' . Person::class . ' p ' .
'JOIN p.center c ' .
'WHERE c.name = :center '
'WHERE EXISTS( ' .
'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();
shuffle($this->personIds);

View File

@ -680,6 +680,11 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
$this->proxyAccompanyingPeriodOpenState = false;
}
public function countResources(): int
{
return $this->resources->count();
}
/**
* This public function is the same but return only true or false.
*/
@ -764,6 +769,18 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
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.
*

View File

@ -12,11 +12,14 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Menu;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\ResidentialAddressRepository;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter;
use Knp\Menu\MenuItem;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
use function count;
/**
* Add menu entrie to person menu.
@ -35,18 +38,28 @@ class PersonMenuBuilder implements LocalMenuBuilderInterface
protected TranslatorInterface $translator;
private ResidentialAddressRepository $residentialAddressRepo;
private Security $security;
public function __construct(
ParameterBagInterface $parameterBag,
Security $security,
TranslatorInterface $translator
TranslatorInterface $translator,
ResidentialAddressRepository $residentialAddressRepo
) {
$this->showAccompanyingPeriod = $parameterBag->get('chill_person.accompanying_period');
$this->security = $security;
$this->translator = $translator;
$this->residentialAddressRepo = $residentialAddressRepo;
}
/**
* @param $menuId
* @param MenuItem $menu
* @param array{person: Person} $parameters
* @return void
*/
public function buildMenu($menuId, MenuItem $menu, array $parameters)
{
$menu->addChild($this->translator->trans('Person details'), [
@ -67,6 +80,8 @@ class PersonMenuBuilder implements LocalMenuBuilderInterface
])
->setExtras([
'order' => 60,
'counter' => 0 < ($nbResidentials = $this->residentialAddressRepo->countByPerson($parameters['person'])) ?
$nbResidentials : null,
]);
$menu->addChild($this->translator->trans('person_resources_menu'), [
@ -77,6 +92,7 @@ class PersonMenuBuilder implements LocalMenuBuilderInterface
])
->setExtras([
'order' => 70,
'counter' => 0 < ($nbResources = $parameters['person']->countResources()) ? $nbResources : null,
]);
$menu->addChild($this->translator->trans('household.person history'), [
@ -111,6 +127,8 @@ class PersonMenuBuilder implements LocalMenuBuilderInterface
])
->setExtras([
'order' => 100,
'counter' => 0 < ($nbAccompanyingPeriod = $parameters['person']->countAccompanyingPeriodInvolved())
? $nbAccompanyingPeriod : null,
]);
}
}

View File

@ -32,6 +32,16 @@ class ResidentialAddressRepository extends ServiceEntityRepository
parent::__construct($registry, ResidentialAddress::class);
}
public function countByPerson(Person $person): int
{
return $this->createQueryBuilder('ra')
->select('COUNT(ra)')
->where('ra.person = :person')
->setParameter('person', $person)
->getQuery()
->getSingleScalarResult();
}
public function buildQueryFindCurrentResidentialAddresses(Person $person, ?DateTimeImmutable $at = null): QueryBuilder
{
$date = null === $at ? new DateTimeImmutable('today') : $at;

View File

@ -22,7 +22,12 @@
<i>{{ $t('course.open_at') }}{{ $d(accompanyingCourse.openingDate.datetime, 'text') }}</i>
</span>
<span v-if="accompanyingCourse.user" class="d-md-block ms-3 ms-md-0">
<span class="item-key">{{ $t('course.referrer') }}:</span> <b>{{ accompanyingCourse.user.text }}</b>
<span class="item-key">{{ $t('course.referrer') }}:</span>&nbsp;
<b>{{ accompanyingCourse.user.text }}</b>
<template v-if="accompanyingCourse.user.isAbsent">
&nbsp;
<span class="badge bg-danger rounded-pill" title="Absent">A</span>
</template>
</span>
</span>
</span>
@ -59,13 +64,15 @@
import ToggleFlags from './Banner/ToggleFlags';
import SocialIssue from './Banner/SocialIssue.vue';
import PersonsAssociated from './Banner/PersonsAssociated.vue';
import UserRenderBoxBadge from 'ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue';
export default {
name: 'Banner',
components: {
ToggleFlags,
SocialIssue,
PersonsAssociated
PersonsAssociated,
UserRenderBoxBadge,
},
computed: {
accompanyingCourse() {

View File

@ -29,7 +29,8 @@ const appMessages = {
emergency: "urgent",
confidential: "confidentiel",
regular: "régulier",
occasional: "ponctuel"
occasional: "ponctuel",
absent: "Absent",
},
origin: {
title: "Origine de la demande",

View File

@ -116,6 +116,7 @@
:filename="d.title"
:can-edit="true"
:execute-before-leave="submitBeforeLeaveToEditor"
@on-stored-object-status-change="onStatusDocumentChanged"
></document-action-buttons-group>
</li>
<li v-if="d.workflows.length === 0">
@ -338,6 +339,10 @@ export default {
this.$store.commit('removeDocument', {key: this.evaluation.key, document: document});
}
},
onStatusDocumentChanged(newStatus) {
console.log('onStatusDocumentChanged', newStatus);
this.$store.commit('statusDocumentChanged', {key: this.evaluation.key, newStatus: newStatus});
},
goToGenerateWorkflowEvaluationDocument({event, link, workflowName, payload}) {
const callback = (data) => {
let evaluation = data.accompanyingPeriodWorkEvaluations.find(e => e.key === this.evaluation.key);

View File

@ -360,7 +360,22 @@ const store = createStore({
state.evaluationsPicked.find(e => e.key === payload.evaluationKey)
.documents.find(d => d.id === payload.id).title = payload.title;
}
}
},
statusDocumentChanged(state, {newStatus, key}) {
const e = state.evaluationsPicked.find(e => e.key === key);
if (typeof e === 'undefined') {
console.error('evaluation not found for given key', {key});
}
const doc = e.documents.find(d => d.storedObject?.id === newStatus.id);
if (typeof doc === 'undefined') {
console.error('document not found', {newStatus});
}
doc.storedObject.status = newStatus.status;
doc.storedObject.type = newStatus.type;
doc.storedObject.filename = newStatus.filename;
},
},
actions: {
updateThirdParty({ commit }, payload) {

View File

@ -1,9 +1,7 @@
<template>
<div class="container usercontainer">
<div class="user-identification">
<span class="name">
{{ item.result.text }}
</span>
<user-render-box-badge :user="item.result"></user-render-box-badge>
</div>
</div>
<div class="right_actions">
@ -16,10 +14,12 @@
<script>
import BadgeEntity from 'ChillMainAssets/vuejs/_components/BadgeEntity.vue';
import UserRenderBoxBadge from 'ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue';
export default {
name: 'SuggestionUser',
components: {
UserRenderBoxBadge,
BadgeEntity
},
props: ['item'],

View File

@ -17,9 +17,13 @@
<div class="list-group vertical-menu {{ 'menu-' ~ menus.name }}">
{% for menu in menus %}
<a class="list-group-item list-group-item-action"
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
href="{{ menu.uri }}">
{{ menu.label|upper }}
{% if menu.extras.counter is defined and menu.extras.counter is not null %}
<span class="badge rounded-pill bg-secondary notification-counter">{{ menu.extras.counter }}</span>
{% endif %}
</a>
{% endfor %}
</div>

View File

@ -115,23 +115,23 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
{
$string = '@person ';
$string .= empty($data['_default']) ? '' : $data['_default'] . ' ';
$string .= $data['_default'] ? '' : $data['_default'] . ' ';
foreach (['firstname', 'lastname', 'gender', 'city'] as $key) {
$string .= empty($data[$key]) ? '' : $key . ':' .
$string .= $data[$key] ? '' : $key . ':' .
// add quote if contains spaces
(strpos($data[$key], ' ') !== false ? '"' . $data[$key] . '"' : $data[$key])
. ' ';
}
foreach (['birthdate', 'birthdate-before', 'birthdate-after'] as $key) {
$string .= empty($data[$key]) ?
$string .= $data[$key] ?
''
:
$key . ':' . $data[$key]->format('Y-m-d') . ' ';
}
$string .= empty($data['phonenumber']) ? '' : 'phonenumber:' . $data['phonenumber']->getNationalNumber();
$string .= $data['phonenumber'] ? '' : 'phonenumber:' . $data['phonenumber']->getNationalNumber();
return $string;
}
@ -162,13 +162,13 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
$phonenumber = new PhoneNumber();
$phonenumber->setNationalNumber($terms['phonenumber']);
} catch (Exception $ex) {
throw new ParsingException("The date for {$key} is "
throw new ParsingException('The date for phonenumber is '
. 'not parsable', 0, $ex);
}
$data['phonenumber'] = $phonenumber ?? null;
}
$data['phonenumber'] = $phonenumber ?? null;
return $data;
}

View File

@ -79,6 +79,11 @@ class SimilarPersonMatcher
->where('SIMILARITY(p.fullnameCanonical, UNACCENT(LOWER(:fullName))) >= :precision')
->andWhere($qb->expr()->in('p.center', ':centers'));
$qb
->setParameter('fullName', $this->personRender->renderString($person, []))
->setParameter('centers', $centers)
->setParameter('precision', $precision);
if (null !== $person->getBirthdate()) {
$qb->andWhere($qb->expr()->orX(
$qb->expr()->eq('p.birthdate', ':personBirthdate'),
@ -90,13 +95,13 @@ class SimilarPersonMatcher
if ($person->getId() !== null) {
$qb->andWhere($qb->expr()->neq('p.id', ':personId'));
$query->setParameter('personId', $person->getId());
$qb->setParameter('personId', $person->getId());
$notDuplicatePersons = $this->personNotDuplicateRepository->findNotDuplicatePerson($person);
if (count($notDuplicatePersons)) {
$qb->andWhere($qb->expr()->notIn('p.id', ':notDuplicatePersons'));
$query->setParameter('notDuplicatePersons', $notDuplicatePersons);
$qb->setParameter('notDuplicatePersons', $notDuplicatePersons);
}
}
@ -109,13 +114,10 @@ class SimilarPersonMatcher
case self::SIMILAR_SEARCH_ORDER_BY_SIMILARITY:
default:
$qb->orderBy('SIMILARITY(p.fullnameCanonical, UNACCENT(LOWER(:fullName)))', 'DESC');
$qb->setParameter('fullName', $this->personRender->renderString($person, []));
}
$qb
->setParameter('fullName', $this->personRender->renderString($person, []))
->setParameter('centers', $centers)
->setParameter('precision', $precision);
dump($qb->getQuery());
return $qb->getQuery()->getResult();
}
}

View File

@ -24,6 +24,7 @@ use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Templating\Entity\PersonRenderInterface;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
@ -51,6 +52,8 @@ class AccompanyingPeriodContext implements
private PersonRenderInterface $personRender;
private PersonRepository $personRepository;
private TranslatableStringHelperInterface $translatableStringHelper;
private TranslatorInterface $translator;
@ -61,6 +64,7 @@ class AccompanyingPeriodContext implements
TranslatableStringHelperInterface $translatableStringHelper,
EntityManagerInterface $em,
PersonRenderInterface $personRender,
PersonRepository $personRepository,
TranslatorInterface $translator,
BaseContextData $baseContextData
) {
@ -69,6 +73,7 @@ class AccompanyingPeriodContext implements
$this->translatableStringHelper = $translatableStringHelper;
$this->em = $em;
$this->personRender = $personRender;
$this->personRepository = $personRepository;
$this->translator = $translator;
$this->baseContextData = $baseContextData;
}
@ -256,6 +261,31 @@ class AccompanyingPeriodContext implements
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() : null;
}
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 AccompanyingPeriod $entity
*/

View File

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Service\DocGenerator;
use Chill\DocGeneratorBundle\Context\DocGeneratorContextWithPublicFormInterface;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork;
@ -18,7 +19,13 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class AccompanyingPeriodWorkContext
/**
* Generate a context for an @link{AccompanyingPeriodWork}.
*
* Although there isn't any document associated to AccompanyingPeriodWork, this context
* is use by @link{AccompanyingPeriodWorkEvaluationContext}.
*/
class AccompanyingPeriodWorkContext implements DocGeneratorContextWithPublicFormInterface
{
private NormalizerInterface $normalizer;
@ -109,8 +116,18 @@ class AccompanyingPeriodWorkContext
return $this->periodContext->hasPublicForm($template, $entity);
}
public function contextGenerationDataNormalize(DocGeneratorTemplate $template, $entity, array $data): array
{
return $this->periodContext->contextGenerationDataNormalize($template, $entity, $data);
}
public function contextGenerationDataDenormalize(DocGeneratorTemplate $template, $entity, array $data): array
{
return $this->periodContext->contextGenerationDataDenormalize($template, $entity, $data);
}
public function storeGenerated(DocGeneratorTemplate $template, StoredObject $storedObject, object $entity, array $contextGenerationData): void
{
// TODO: Implement storeGenerated() method.
// currently, no document associated with a AccompanyingPeriodWork
}
}

View File

@ -174,6 +174,18 @@ class AccompanyingPeriodWorkEvaluationContext implements
->hasPublicForm($template, $entity->getAccompanyingPeriodWork());
}
public function contextGenerationDataNormalize(DocGeneratorTemplate $template, $entity, array $data): array
{
return $this->accompanyingPeriodWorkContext
->contextGenerationDataNormalize($template, $entity, $data);
}
public function contextGenerationDataDenormalize(DocGeneratorTemplate $template, $entity, array $data): array
{
return $this->accompanyingPeriodWorkContext
->contextGenerationDataDenormalize($template, $entity, $data);
}
public function storeGenerated(DocGeneratorTemplate $template, StoredObject $storedObject, object $entity, array $contextGenerationData): void
{
$doc = new AccompanyingPeriodWorkEvaluationDocument();

View File

@ -21,11 +21,13 @@ use Chill\DocStoreBundle\Repository\DocumentCategoryRepository;
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Form\Type\ScopePickerType;
use Chill\MainBundle\Repository\ScopeRepositoryInterface;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\PersonRepository;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
@ -55,6 +57,8 @@ final class PersonContext implements PersonContextInterface
private NormalizerInterface $normalizer;
private ScopeRepositoryInterface $scopeRepository;
private Security $security;
private bool $showScopes;
@ -71,6 +75,7 @@ final class PersonContext implements PersonContextInterface
EntityManagerInterface $em,
NormalizerInterface $normalizer,
ParameterBagInterface $parameterBag,
ScopeRepositoryInterface $scopeRepository,
Security $security,
TranslatorInterface $translator,
TranslatableStringHelperInterface $translatableStringHelper
@ -81,6 +86,7 @@ final class PersonContext implements PersonContextInterface
$this->documentCategoryRepository = $documentCategoryRepository;
$this->em = $em;
$this->normalizer = $normalizer;
$this->scopeRepository = $scopeRepository;
$this->security = $security;
$this->showScopes = $parameterBag->get('chill_main')['acl']['form_show_scopes'];
$this->translator = $translator;
@ -211,6 +217,38 @@ final class PersonContext implements PersonContextInterface
return true;
}
/**
* @param Person $entity
*/
public function contextGenerationDataNormalize(DocGeneratorTemplate $template, $entity, array $data): array
{
$scope = $data['scope'] ?? null;
return [
'title' => $data['title'] ?? '',
'scope_id' => $scope instanceof Scope ? $scope->getId() : null,
];
}
/**
* @param Person $entity
*/
public function contextGenerationDataDenormalize(DocGeneratorTemplate $template, $entity, array $data): array
{
if (!isset($data['scope'])) {
$scope = null;
} else {
if (null === $scope = $this->scopeRepository->find($data['scope'])) {
throw new \UnexpectedValueException('scope not found');
}
}
return [
'title' => $data['title'] ?? '',
'scope' => $scope,
];
}
/**
* @param Person $entity
*/

View File

@ -48,6 +48,10 @@ interface PersonContextInterface extends DocGeneratorContextWithAdminFormInterfa
*/
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 Person $entity
*/

View File

@ -17,6 +17,7 @@ use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Chill\ThirdPartyBundle\Form\Type\PickThirdpartyDynamicType;
use Chill\ThirdPartyBundle\Repository\ThirdPartyRepository;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@ -30,12 +31,16 @@ class PersonContextWithThirdParty implements DocGeneratorContextWithAdminFormInt
private PersonContextInterface $personContext;
private ThirdPartyRepository $thirdPartyRepository;
public function __construct(
PersonContextInterface $personContext,
NormalizerInterface $normalizer
NormalizerInterface $normalizer,
ThirdPartyRepository $thirdPartyRepository
) {
$this->personContext = $personContext;
$this->normalizer = $normalizer;
$this->thirdPartyRepository = $thirdPartyRepository;
}
public function adminFormReverseTransform(array $data): array
@ -123,6 +128,26 @@ class PersonContextWithThirdParty implements DocGeneratorContextWithAdminFormInt
return true;
}
public function contextGenerationDataNormalize(DocGeneratorTemplate $template, $entity, array $data): array
{
return array_merge(
[
'thirdParty' => null === $data['thirdParty'] ? null : $data['thirdParty']->getId(),
],
$this->personContext->contextGenerationDataNormalize($template, $entity, $data),
);
}
public function contextGenerationDataDenormalize(DocGeneratorTemplate $template, $entity, array $data): array
{
return array_merge(
[
'thirdParty' => null === $data['thirdParty'] ? null : $this->thirdPartyRepository->find($data['thirdParty']),
],
$this->personContext->contextGenerationDataDenormalize($template, $entity, $data),
);
}
public function storeGenerated(DocGeneratorTemplate $template, StoredObject $storedObject, object $entity, array $contextGenerationData): void
{
$this->personContext->storeGenerated($template, $storedObject, $entity, $contextGenerationData);

View File

@ -1,6 +1,5 @@
// this file loads all assets from the Chill person bundle
module.exports = function(encore, entries)
{
module.exports = function(encore, entries) {
encore.addEntry('page_wopi_editor', __dirname + '/src/Resources/public/page/editor/index.js');
//home/julien/dev/département-vendee/chill/vendor/chill-project/chill-bundles/src/Bundle/ChillWopiBundle/src/Resources/public/page/editor/index.js
encore.addEntry('mod_reload_page', __dirname + '/src/Resources/public/module/pending/index');
};

View File

@ -26,6 +26,10 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Templating\EngineInterface;
use Twig\Environment;
/**
* @internal
@ -35,6 +39,8 @@ final class Editor
{
private DocumentManagerInterface $documentManager;
private EngineInterface $engine;
private JWTTokenManagerInterface $JWTTokenManager;
private Psr17Interface $psr17;
@ -49,18 +55,24 @@ final class Editor
private DiscoveryInterface $wopiDiscovery;
private NormalizerInterface $normalizer;
public function __construct(
ConfigurationInterface $wopiConfiguration,
DiscoveryInterface $wopiDiscovery,
DocumentManagerInterface $documentManager,
EngineInterface $engine,
JWTTokenManagerInterface $JWTTokenManager,
NormalizerInterface $normalizer,
ResponderInterface $responder,
Security $security,
Psr17Interface $psr17,
RouterInterface $router
) {
$this->documentManager = $documentManager;
$this->engine = $engine;
$this->JWTTokenManager = $JWTTokenManager;
$this->normalizer = $normalizer;
$this->wopiConfiguration = $wopiConfiguration;
$this->wopiDiscovery = $wopiDiscovery;
$this->responder = $responder;
@ -87,6 +99,22 @@ final class Editor
throw new NotFoundHttpException(sprintf('Unable to find object %s', $fileId));
}
if (StoredObject::STATUS_FAILURE === $storedObject->getStatus()) {
return new Response(
$this->engine
->render('@ChillWopi/Editor/stored_object_failure.html.twig')
);
} elseif (StoredObject::STATUS_PENDING === $storedObject->getStatus()) {
return new Response(
$this->engine
->render('@ChillWopi/Editor/stored_object_pending.html.twig', [
'storedObject' => $this->normalizer->normalize($storedObject, 'json', [
AbstractNormalizer::GROUPS => ['read']
])
])
);
}
if ([] === $discoverExtension = $this->wopiDiscovery->discoverMimeType($storedObject->getType())) {
throw new Exception(sprintf('Unable to find mime type %s', $storedObject->getType()));
}

View File

@ -0,0 +1,40 @@
import {is_object_ready} from "../../../../../../ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/helpers";
import {
StoredObject,
StoredObjectStatus,
StoredObjectStatusChange
} from "../../../../../../ChillDocStoreBundle/Resources/public/types";
async function reload_if_needed(stored_object: StoredObject, i: number): Promise<void> {
let current_status = await is_object_ready(stored_object);
if (stored_object.status !== current_status.status) {
window.location.reload();
}
wait_before_reload(stored_object, i + 1);
return Promise.resolve();
}
function wait_before_reload(stored_object: StoredObject, i: number): void {
/**
* value of the timeout. Set to 5 sec during the first 10 minutes, then every 1 minute
*/
let timeout = i < 1200 ? 5000 : 60000;
setTimeout(
reload_if_needed,
timeout,
stored_object,
i
);
}
window.addEventListener('DOMContentLoaded', async function (e) {
if (undefined === (<any>window).stored_object) {
console.error('window.stored_object is undefined');
throw Error('window.stored_object is undefined');
}
let stored_object = JSON.parse((<any>window).stored_object) as StoredObject;
reload_if_needed(stored_object, 0);
});

View File

@ -0,0 +1,15 @@
{% extends 'ChillMainBundle::layout.html.twig' %}
{% block content %}
<div class="alert alert-danger">
{{ 'docgen.Doc generation failed'|trans }}
</div>
<ul class="sticky-form-buttons record_actions">
<li class="cancel">
<a href="{{ chill_return_path_or('chill_main_homepage') }}" class="btn btn-cancel">
{{ 'Cancel'|trans|chill_return_path_label }}
</a>
</li>
</ul>
{% endblock %}

View File

@ -0,0 +1,36 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block js %}
<script type="application/javascript">
window.stored_object = '{{ storedObject|json_encode|escape('js') }}'
</script>
{{ encore_entry_script_tags('mod_reload_page') }}
{% endblock %}
{% block css %}
{{ encore_entry_link_tags('mod_reload_page') }}
{% endblock %}
{% block content %}
<div class="alert alert-danger text-center">
<div>
<p>
{{ 'docgen.Doc generation is pending'|trans }}
</p>
</div>
<div>
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
<span class="sr-only">Loading...</span>
</div>
</div>
<ul class="sticky-form-buttons record_actions">
<li class="cancel">
<a href="{{ chill_return_path_or('chill_main_homepage') }}" class="btn btn-cancel">
{{ 'docgen.Come back later'|trans|chill_return_path_label }}
</a>
</li>
</ul>
{% endblock content %}