Merge remote-tracking branch 'origin/111_exports_suite' into calendar/finalization

This commit is contained in:
2022-11-25 15:29:17 +01:00
169 changed files with 7481 additions and 1515 deletions

View File

@@ -11,23 +11,32 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Form\SavedExportType;
use Chill\MainBundle\Form\Type\Export\ExportType;
use Chill\MainBundle\Form\Type\Export\FormatterType;
use Chill\MainBundle\Form\Type\Export\PickCenterType;
use Chill\MainBundle\Redis\ChillRedis;
use Chill\MainBundle\Security\Authorization\SavedExportVoter;
use Doctrine\ORM\EntityManagerInterface;
use LogicException;
use Psr\Log\LoggerInterface;
use RedisException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
use function count;
use function serialize;
use function unserialize;
@@ -38,35 +47,37 @@ use function unserialize;
*/
class ExportController extends AbstractController
{
private EntityManagerInterface $entityManager;
/**
* @var ExportManager
*/
protected $exportManager;
private $exportManager;
/**
* @var FormFactoryInterface
*/
protected $formFactory;
private $formFactory;
/**
* @var LoggerInterface
*/
protected $logger;
private $logger;
/**
* @var ChillRedis
*/
protected $redis;
private $redis;
/**
* @var SessionInterface
*/
protected $session;
private $session;
/**
* @var TranslatorInterface
*/
protected $translator;
private $translator;
public function __construct(
ChillRedis $chillRedis,
@@ -74,8 +85,10 @@ class ExportController extends AbstractController
FormFactoryInterface $formFactory,
LoggerInterface $logger,
SessionInterface $session,
TranslatorInterface $translator
TranslatorInterface $translator,
EntityManagerInterface $entityManager
) {
$this->entityManager = $entityManager;
$this->redis = $chillRedis;
$this->exportManager = $exportManager;
$this->formFactory = $formFactory;
@@ -142,6 +155,29 @@ class ExportController extends AbstractController
);
}
/**
* @Route("/{_locale}/exports/generate-from-saved/{id}", name="chill_main_export_generate_from_saved")
*
* @throws RedisException
*/
public function generateFromSavedExport(SavedExport $savedExport): RedirectResponse
{
$this->denyAccessUnlessGranted(SavedExportVoter::GENERATE, $savedExport);
$key = md5(uniqid((string) mt_rand(), false));
$this->redis->setEx($key, 3600, serialize($savedExport->getOptions()));
return $this->redirectToRoute(
'chill_main_export_download',
[
'alias' => $savedExport->getExportAlias(),
'key' => $key, 'prevent_save' => true,
'returnPath' => $this->generateUrl('chill_main_export_saved_list_my'),
]
);
}
/**
* Render the list of available exports.
*/
@@ -203,6 +239,46 @@ class ExportController extends AbstractController
}
}
/**
* @Route("/{_locale}/export/save-from-key/{alias}/{key}", name="chill_main_export_save_from_key")
*/
public function saveFromKey(string $alias, string $key, Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$user = $this->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException();
}
$data = $this->rebuildRawData($key);
$savedExport = new SavedExport();
$savedExport
->setOptions($data)
->setExportAlias($alias)
->setUser($user);
$form = $this->createForm(SavedExportType::class, $savedExport);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->persist($savedExport);
$this->entityManager->flush();
return $this->redirectToRoute('chill_main_export_index');
}
return $this->render(
'@ChillMain/SavedExport/new.html.twig',
[
'form' => $form->createView(),
'saved_export' => $savedExport,
]
);
}
/**
* create a form to show on different steps.
*
@@ -418,28 +494,7 @@ class ExportController extends AbstractController
protected function rebuildData($key)
{
if (null === $key) {
throw $this->createNotFoundException('key does not exists');
}
if ($this->redis->exists($key) !== 1) {
$this->addFlash('error', $this->translator->trans('This report is not available any more'));
throw $this->createNotFoundException('key does not exists');
}
$serialized = $this->redis->get($key);
if (false === $serialized) {
throw new LogicException('the key could not be reached from redis');
}
$rawData = unserialize($serialized);
$this->logger->notice('[export] choices for an export unserialized', [
'key' => $key,
'rawData' => json_encode($rawData),
]);
$rawData = $this->rebuildRawData($key);
$alias = $rawData['alias'];
@@ -585,4 +640,32 @@ class ExportController extends AbstractController
throw new LogicException("the step {$step} is not defined.");
}
}
private function rebuildRawData(string $key): array
{
if (null === $key) {
throw $this->createNotFoundException('key does not exists');
}
if ($this->redis->exists($key) !== 1) {
$this->addFlash('error', $this->translator->trans('This report is not available any more'));
throw $this->createNotFoundException('key does not exists');
}
$serialized = $this->redis->get($key);
if (false === $serialized) {
throw new LogicException('the key could not be reached from redis');
}
$rawData = unserialize($serialized);
$this->logger->notice('[export] choices for an export unserialized', [
'key' => $key,
'rawData' => json_encode($rawData),
]);
return $rawData;
}
}

View File

@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Export\GroupedExportInterface;
use Chill\MainBundle\Form\SavedExportType;
use Chill\MainBundle\Repository\SavedExportRepositoryInterface;
use Chill\MainBundle\Security\Authorization\SavedExportVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Templating\EngineInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function count;
class SavedExportController
{
private EntityManagerInterface $entityManager;
private ExportManager $exportManager;
private FormFactoryInterface $formFactory;
private SavedExportRepositoryInterface $savedExportRepository;
private Security $security;
private SessionInterface $session;
private EngineInterface $templating;
private TranslatorInterface $translator;
private UrlGeneratorInterface $urlGenerator;
public function __construct(
EngineInterface $templating,
EntityManagerInterface $entityManager,
ExportManager $exportManager,
FormFactoryInterface $formBuilder,
SavedExportRepositoryInterface $savedExportRepository,
Security $security,
SessionInterface $session,
TranslatorInterface $translator,
UrlGeneratorInterface $urlGenerator
) {
$this->exportManager = $exportManager;
$this->entityManager = $entityManager;
$this->formFactory = $formBuilder;
$this->savedExportRepository = $savedExportRepository;
$this->security = $security;
$this->session = $session;
$this->templating = $templating;
$this->translator = $translator;
$this->urlGenerator = $urlGenerator;
}
/**
* @Route("/{_locale}/exports/saved/{id}/delete", name="chill_main_export_saved_delete")
*/
public function delete(SavedExport $savedExport, Request $request): Response
{
if (!$this->security->isGranted(SavedExportVoter::DELETE, $savedExport)) {
throw new AccessDeniedHttpException();
}
$form = $this->formFactory->create();
$form->add('submit', SubmitType::class, ['label' => 'Delete']);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->remove($savedExport);
$this->entityManager->flush();
$this->session->getFlashBag()->add('success', $this->translator->trans('saved_export.Export is deleted'));
return new RedirectResponse(
$this->urlGenerator->generate('chill_main_export_saved_list_my')
);
}
return new Response(
$this->templating->render(
'@ChillMain/SavedExport/delete.html.twig',
[
'saved_export' => $savedExport,
'delete_form' => $form->createView(),
]
)
);
}
/**
* @Route("/{_locale}/exports/saved/{id}/edit", name="chill_main_export_saved_edit")
*/
public function edit(SavedExport $savedExport, Request $request): Response
{
if (!$this->security->isGranted(SavedExportVoter::EDIT, $savedExport)) {
throw new AccessDeniedHttpException();
}
$form = $this->formFactory->create(SavedExportType::class, $savedExport);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->flush();
$this->session->getFlashBag()->add('success', $this->translator->trans('saved_export.Saved export is saved!'));
return new RedirectResponse(
$this->urlGenerator->generate('chill_main_export_saved_list_my')
);
}
return new Response(
$this->templating->render(
'@ChillMain/SavedExport/edit.html.twig',
[
'form' => $form->createView(),
]
)
);
}
/**
* @Route("/{_locale}/exports/saved/my", name="chill_main_export_saved_list_my")
*/
public function list(): Response
{
$user = $this->security->getUser();
if (!$this->security->isGranted('ROLE_USER') || !$user instanceof User) {
throw new AccessDeniedHttpException();
}
$exports = $this->savedExportRepository->findByUser($user, ['title' => 'ASC']);
// group by center
/** @var array<string, array{saved: SavedExport, export: ExportInterface}> $exportsGrouped */
$exportsGrouped = [];
foreach ($exports as $savedExport) {
$export = $this->exportManager->getExport($savedExport->getExportAlias());
$exportsGrouped[
$export instanceof GroupedExportInterface
? $this->translator->trans($export->getGroup()) : '_'
][] = ['saved' => $savedExport, 'export' => $export];
}
ksort($exportsGrouped);
return new Response(
$this->templating->render(
'@ChillMain/SavedExport/index.html.twig',
[
'grouped_exports' => $exportsGrouped,
'total' => count($exports),
]
)
);
}
}

View File

@@ -25,15 +25,19 @@ use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface;
use Chill\MainBundle\Doctrine\DQL\Age;
use Chill\MainBundle\Doctrine\DQL\Extract;
use Chill\MainBundle\Doctrine\DQL\GetJsonFieldByKey;
use Chill\MainBundle\Doctrine\DQL\Greatest;
use Chill\MainBundle\Doctrine\DQL\JsonAggregate;
use Chill\MainBundle\Doctrine\DQL\JsonbArrayLength;
use Chill\MainBundle\Doctrine\DQL\JsonbExistsInArray;
use Chill\MainBundle\Doctrine\DQL\JsonExtract;
use Chill\MainBundle\Doctrine\DQL\Least;
use Chill\MainBundle\Doctrine\DQL\OverlapsI;
use Chill\MainBundle\Doctrine\DQL\Replace;
use Chill\MainBundle\Doctrine\DQL\Similarity;
use Chill\MainBundle\Doctrine\DQL\STContains;
use Chill\MainBundle\Doctrine\DQL\StrictWordSimilarityOPS;
use Chill\MainBundle\Doctrine\DQL\STX;
use Chill\MainBundle\Doctrine\DQL\STY;
use Chill\MainBundle\Doctrine\DQL\ToChar;
use Chill\MainBundle\Doctrine\DQL\Unaccent;
use Chill\MainBundle\Doctrine\ORM\Hydration\FlatHierarchyEntityHydrator;
@@ -245,6 +249,10 @@ class ChillMainExtension extends Extension implements
'STRICT_WORD_SIMILARITY_OPS' => StrictWordSimilarityOPS::class,
'ST_CONTAINS' => STContains::class,
'JSONB_ARRAY_LENGTH' => JsonbArrayLength::class,
'ST_X' => STX::class,
'ST_Y' => STY::class,
'GREATEST' => Greatest::class,
'LEAST' => LEAST::class,
],
'datetime_functions' => [
'EXTRACT' => Extract::class,

View File

@@ -0,0 +1,57 @@
<?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\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
/**
* Postgresql GREATEST function.
*
* Borrowed from https://github.com/beberlei/DoctrineExtensions/blob/master/src/Query/Postgresql/Greatest.php
* (https://github.com/beberlei/DoctrineExtensions/blob/master/LICENSE) and
* https://gist.github.com/olimsaidov/4bbd530b1b645ce75e1bbb781b5dd91f
*/
class Greatest extends FunctionNode
{
/**
* @var array|Node[]
*/
private array $exprs = [];
public function getSql(SqlWalker $sqlWalker)
{
return 'GREATEST(' . implode(', ', array_map(static function (Node $expr) use ($sqlWalker) {
return $expr->dispatch($sqlWalker);
}, $this->exprs)) . ')';
}
public function parse(Parser $parser)
{
$this->exprs = [];
$lexer = $parser->getLexer();
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$this->exprs[] = $parser->ArithmeticPrimary();
while (Lexer::T_COMMA === $lexer->lookahead['type']) {
$parser->match(Lexer::T_COMMA);
$this->exprs[] = $parser->ArithmeticPrimary();
}
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
}
}

View File

@@ -0,0 +1,57 @@
<?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\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
/**
* Postgresql LEAST function.
*
* Borrowed from https://github.com/beberlei/DoctrineExtensions/blob/master/src/Query/Postgresql/Least.php
* (https://github.com/beberlei/DoctrineExtensions/blob/master/LICENSE) and
* https://gist.github.com/olimsaidov/4bbd530b1b645ce75e1bbb781b5dd91f
*/
class Least extends FunctionNode
{
/**
* @var array|Node[]
*/
private array $exprs = [];
public function getSql(SqlWalker $sqlWalker)
{
return 'LEAST(' . implode(', ', array_map(static function (Node $expr) use ($sqlWalker) {
return $expr->dispatch($sqlWalker);
}, $this->exprs)) . ')';
}
public function parse(Parser $parser)
{
$this->exprs = [];
$lexer = $parser->getLexer();
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$this->exprs[] = $parser->ArithmeticPrimary();
while (Lexer::T_COMMA === $lexer->lookahead['type']) {
$parser->match(Lexer::T_COMMA);
$this->exprs[] = $parser->ArithmeticPrimary();
}
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
}
}

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\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
class STX extends FunctionNode
{
private $field;
public function getSql(SqlWalker $sqlWalker)
{
return sprintf('ST_X(%s)', $this->field->dispatch($sqlWalker));
}
public function parse(Parser $parser)
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$this->field = $parser->ArithmeticExpression();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
}
}

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\MainBundle\Doctrine\DQL;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
class STY extends FunctionNode
{
private $field;
public function getSql(SqlWalker $sqlWalker)
{
return sprintf('ST_Y(%s)', $this->field->dispatch($sqlWalker));
}
public function parse(Parser $parser)
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$this->field = $parser->ArithmeticExpression();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
}
}

View File

@@ -15,6 +15,8 @@ use Chill\MainBundle\Doctrine\Model\Point;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use DateTime;
use DateTimeInterface;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -97,6 +99,23 @@ class Address
*/
private $floor;
/**
* List of geographical units and addresses.
*
* This list is computed by a materialized view. It won't be populated until a refresh is done
* on the materialized view.
*
* @var Collection<int, GeographicalUnit>|GeographicalUnit[]
* @readonly
* @ORM\ManyToMany(targetEntity=GeographicalUnit::class)
* @ORM\JoinTable(
* name="view_chill_main_address_geographical_unit",
* joinColumns={@ORM\JoinColumn(name="address_id")},
* inverseJoinColumns={@ORM\JoinColumn(name="geographical_unit_id")}
* )
*/
private Collection $geographicalUnits;
/**
* @var int
*
@@ -104,8 +123,9 @@ class Address
* @ORM\Column(name="id", type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
* @Groups({"write"})
* @readonly
*/
private $id;
private ?int $id = null;
/**
* True if the address is a "no address", aka homeless person, ...
@@ -190,6 +210,7 @@ class Address
public function __construct()
{
$this->validFrom = new DateTime();
$this->geographicalUnits = new ArrayCollection();
}
public static function createFromAddress(Address $original): Address
@@ -273,6 +294,14 @@ class Address
return $this->floor;
}
/**
* @return Collection<int, GeographicalUnit>|GeographicalUnit[]
*/
public function getGeographicalUnits(): Collection
{
return $this->geographicalUnits;
}
/**
* Get id.
*

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Entity\GeographicalUnit;
/**
* Simple GeographialUnit Data Transfer Object.
*
* This allow to get access to id, unitName, unitRefId, and layer's id
*/
class SimpleGeographicalUnitDTO
{
/**
* @readonly
* @psalm-readonly
*/
public int $id;
/**
* @readonly
* @psalm-readonly
*/
public int $layerId;
/**
* @readonly
* @psalm-readonly
*/
public string $unitName;
/**
* @readonly
* @psalm-readonly
*/
public string $unitRefId;
public function __construct(int $id, string $unitName, string $unitRefId, int $layerId)
{
$this->id = $id;
$this->unitName = $unitName;
$this->unitRefId = $unitRefId;
$this->layerId = $layerId;
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Entity;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity
* @ORM\Table(name="chill_main_saved_export")
*/
class SavedExport implements TrackCreationInterface, TrackUpdateInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
/**
* @ORM\Column(type="text", nullable=false, options={"default": ""})
* @Assert\NotBlank
*/
private string $description = '';
/**
* @ORM\Column(type="text", nullable=false, options={"default": ""})
*/
private string $exportAlias;
/**
* @ORM\Id
* @ORM\Column(name="id", type="uuid", unique="true")
* @ORM\GeneratedValue(strategy="NONE")
*/
private UuidInterface $id;
/**
* @ORM\Column(type="json", nullable=false, options={"default": "[]"})
*/
private array $options = [];
/**
* @ORM\Column(type="text", nullable=false, options={"default": ""})
* @Assert\NotBlank
*/
private string $title = '';
/**
* @ORM\ManyToOne(targetEntity=User::class)
*/
private User $user;
public function __construct()
{
$this->id = Uuid::uuid4();
}
public function getDescription(): string
{
return $this->description;
}
public function getExportAlias(): string
{
return $this->exportAlias;
}
public function getId(): UuidInterface
{
return $this->id;
}
public function getOptions(): array
{
return $this->options;
}
public function getTitle(): string
{
return $this->title;
}
public function getUser(): User
{
return $this->user;
}
public function setDescription(?string $description): SavedExport
{
$this->description = (string) $description;
return $this;
}
public function setExportAlias(string $exportAlias): SavedExport
{
$this->exportAlias = $exportAlias;
return $this;
}
public function setOptions(array $options): SavedExport
{
$this->options = $options;
return $this;
}
public function setTitle(?string $title): SavedExport
{
$this->title = (string) $title;
return $this;
}
public function setUser(User $user): SavedExport
{
$this->user = $user;
return $this;
}
}

View File

@@ -445,6 +445,8 @@ class SpreadSheetFormatter implements FormatterInterface
$this->initializeCache($key);
}
$value = null === $value ? '' : $value;
return call_user_func($this->cacheDisplayableResult[$key], $value);
}

View File

@@ -20,11 +20,12 @@ use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use RuntimeException;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function array_key_exists;
use function array_keys;
use function array_map;
@@ -80,7 +81,7 @@ class SpreadsheetListFormatter implements FormatterInterface
*
* @uses appendAggregatorForm
*
* @param type $exportAlias
* @param string $exportAlias
*/
public function buildForm(
FormBuilderInterface $builder,
@@ -144,8 +145,6 @@ class SpreadsheetListFormatter implements FormatterInterface
$i = 1;
foreach ($result as $row) {
$line = [];
if (true === $this->formatterData['numerotation']) {
$worksheet->setCellValue('A' . ($i + 1), (string) $i);
}
@@ -155,13 +154,22 @@ class SpreadsheetListFormatter implements FormatterInterface
foreach ($row as $key => $value) {
$row = $a . ($i + 1);
if ($value instanceof DateTimeInterface) {
$worksheet->setCellValue($row, Date::PHPToExcel($value));
$worksheet->getStyle($row)
->getNumberFormat()
->setFormatCode(NumberFormat::FORMAT_DATE_DDMMYYYY);
$formattedValue = $this->getLabel($key, $value);
if ($formattedValue instanceof DateTimeInterface) {
$worksheet->setCellValue($row, Date::PHPToExcel($formattedValue));
if ($formattedValue->format('His') === '000000') {
$worksheet->getStyle($row)
->getNumberFormat()
->setFormatCode(NumberFormat::FORMAT_DATE_DDMMYYYY);
} else {
$worksheet->getStyle($row)
->getNumberFormat()
->setFormatCode(NumberFormat::FORMAT_DATE_DATETIME);
}
} else {
$worksheet->setCellValue($row, $this->getLabel($key, $value));
$worksheet->setCellValue($row, $formattedValue);
}
++$a;
}
@@ -259,6 +267,10 @@ class SpreadsheetListFormatter implements FormatterInterface
foreach ($keys as $key) {
// get an array with all values for this key if possible
$values = array_map(static function ($v) use ($key) {
if (!array_key_exists($key, $v)) {
throw new RuntimeException(sprintf('This key does not exists: %s. Available keys are %s', $key, implode(', ', array_keys($v))));
}
return $v[$key];
}, $this->result);
// store the label in the labelsCache property

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export\Helper;
use DateTime;
use Exception;
use Symfony\Contracts\Translation\TranslatorInterface;
class DateTimeHelper
{
private TranslatorInterface $translator;
public function __construct(TranslatorInterface $translator)
{
$this->translator = $translator;
}
public function getLabel($header): callable
{
return function ($value) use ($header) {
if ('_header' === $value) {
return $this->translator->trans($header);
}
if (null === $value) {
return '';
}
// warning: won't work with DateTimeImmutable as we reset time a few lines later
$date = DateTime::createFromFormat('Y-m-d', $value);
$hasTime = false;
if (false === $date) {
$date = DateTime::createFromFormat('Y-m-d H:i:s', $value);
$hasTime = true;
}
// check that the creation could occurs.
if (false === $date) {
throw new Exception(sprintf('The value %s could '
. 'not be converted to %s', $value, DateTime::class));
}
if (!$hasTime) {
$date->setTime(0, 0, 0);
}
return $date;
};
}
}

View File

@@ -0,0 +1,426 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export\Helper;
use Chill\MainBundle\Entity\GeographicalUnit;
use Chill\MainBundle\Entity\GeographicalUnitLayer;
use Chill\MainBundle\Repository\AddressRepository;
use Chill\MainBundle\Repository\GeographicalUnitLayerRepositoryInterface;
use Chill\MainBundle\Templating\Entity\AddressRender;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\ORM\QueryBuilder;
use LogicException;
use function array_key_exists;
use function count;
use function in_array;
use function strlen;
/**
* Helps to load addresses and format them in list.
*/
class ExportAddressHelper
{
/**
* Compute all the F_* constants.
*/
public const F_ALL =
self::F_ATTRIBUTES | self::F_BUILDING | self::F_COUNTRY |
self::F_GEOM | self::F_POSTAL_CODE | self::F_STREET | self::F_GEOGRAPHICAL_UNITS;
public const F_AS_STRING = 0b00010000;
public const F_ATTRIBUTES = 0b01000000;
public const F_BUILDING = 0b00001000;
public const F_COUNTRY = 0b00000001;
public const F_GEOGRAPHICAL_UNITS = 0b1000000000;
public const F_GEOM = 0b00100000;
public const F_POSTAL_CODE = 0b00000010;
public const F_STREET = 0b00000100;
private const ALL = [
'country' => self::F_COUNTRY,
'postal_code' => self::F_POSTAL_CODE,
'street' => self::F_STREET,
'building' => self::F_BUILDING,
'string' => self::F_AS_STRING,
'geom' => self::F_GEOM,
'attributes' => self::F_ATTRIBUTES,
'geographical_units' => self::F_GEOGRAPHICAL_UNITS,
];
private const COLUMN_MAPPING = [
'country' => ['country'],
'postal_code' => ['postcode_code', 'postcode_name'],
'street' => ['street', 'streetNumber'],
'building' => ['buildingName', 'corridor', 'distribution', 'extra', 'flat', 'floor', 'steps'],
'string' => ['_as_string'],
'attributes' => ['isNoAddress', 'confidential', 'id'],
'geom' => ['_lat', '_lon'],
'geographical_units' => ['_unit_names', '_unit_refs'],
];
private AddressRender $addressRender;
private AddressRepository $addressRepository;
private GeographicalUnitLayerRepositoryInterface $geographicalUnitLayerRepository;
private TranslatableStringHelperInterface $translatableStringHelper;
/**
* @var array<string, string, GeographicalUnitLayer>>|null
*/
private ?array $unitNamesKeysCache = [];
/**
* @var array<string, array<string, GeographicalUnitLayer>>|null
*/
private ?array $unitRefsKeysCache = [];
public function __construct(
AddressRender $addressRender,
AddressRepository $addressRepository,
GeographicalUnitLayerRepositoryInterface $geographicalUnitLayerRepository,
TranslatableStringHelperInterface $translatableStringHelper
) {
$this->addressRepository = $addressRepository;
$this->geographicalUnitLayerRepository = $geographicalUnitLayerRepository;
$this->translatableStringHelper = $translatableStringHelper;
$this->addressRender = $addressRender;
}
public function addSelectClauses(int $params, QueryBuilder $queryBuilder, $entityName = 'address', $prefix = 'add')
{
foreach (self::ALL as $key => $bitmask) {
if (($params & $bitmask) === $bitmask) {
foreach (self::COLUMN_MAPPING[$key] as $field) {
switch ($field) {
case 'id':
case '_as_string':
$queryBuilder->addSelect(sprintf('%s.id AS %s%s', $entityName, $prefix, $field));
break;
case 'street':
case 'streetNumber':
case 'floor':
case 'corridor':
case 'steps':
case 'buildingName':
case 'flat':
case 'distribution':
case 'extra':
$queryBuilder->addSelect(sprintf('%s.%s AS %s%s', $entityName, $field, $prefix, $field));
break;
case 'country':
case 'postcode_name':
case 'postcode_code':
$postCodeAlias = sprintf('%spostcode_t', $prefix);
if (!in_array($postCodeAlias, $queryBuilder->getAllAliases(), true)) {
$queryBuilder->leftJoin($entityName . '.postcode', $postCodeAlias);
}
if ('postcode_name' === $field) {
$queryBuilder->addSelect(sprintf('%s.%s AS %s%s', $postCodeAlias, 'name', $prefix, $field));
break;
}
if ('postcode_code' === $field) {
$queryBuilder->addSelect(sprintf('%s.%s AS %s%s', $postCodeAlias, 'code', $prefix, $field));
break;
}
$countryAlias = sprintf('%scountry_t', $prefix);
if (!in_array($countryAlias, $queryBuilder->getAllAliases(), true)) {
$queryBuilder->leftJoin(sprintf('%s.country', $postCodeAlias), $countryAlias);
}
$queryBuilder->addSelect(sprintf('%s.%s AS %s%s', $countryAlias, 'name', $prefix, $field));
break;
case 'isNoAddress':
case 'confidential':
$queryBuilder->addSelect(sprintf('CASE WHEN %s.%s = \'TRUE\' THEN 1 ELSE 0 END AS %s%s', $entityName, $field, $prefix, $field));
break;
case '_lat':
$queryBuilder->addSelect(sprintf('ST_Y(%s.point) AS %s%s', $entityName, $prefix, $field));
break;
case '_lon':
$queryBuilder->addSelect(sprintf('ST_X(%s.point) AS %s%s', $entityName, $prefix, $field));
break;
case '_unit_names':
foreach ($this->generateKeysForUnitsNames($prefix) as $alias => $layer) {
$queryBuilder
->addSelect(
sprintf(
'(SELECT AGGREGATE(u_n_%s_%s.unitName) FROM %s u_n_%s_%s WHERE u_n_%s_%s MEMBER OF %s.geographicalUnits AND u_n_%s_%s.layer = :layer_%s_%s) AS %s',
$prefix,
$layer->getId(),
GeographicalUnit::class,
$prefix,
$layer->getId(),
$prefix,
$layer->getId(),
$entityName,
$prefix,
$layer->getId(),
$prefix,
$layer->getId(),
$alias
)
)
->setParameter(sprintf('layer_%s_%s', $prefix, $layer->getId()), $layer);
}
break;
case '_unit_refs':
foreach ($this->generateKeysForUnitsRefs($prefix) as $alias => $layer) {
$queryBuilder
->addSelect(
sprintf(
'(SELECT AGGREGATE(u_r_%s_%s.unitRefId) FROM %s u_r_%s_%s WHERE u_r_%s_%s MEMBER OF %s.geographicalUnits AND u_r_%s_%s.layer = :layer_%s_%s) AS %s',
$prefix,
$layer->getId(),
GeographicalUnit::class,
$prefix,
$layer->getId(),
$prefix,
$layer->getId(),
$entityName,
$prefix,
$layer->getId(),
$prefix,
$layer->getId(),
$alias
)
)
->setParameter(sprintf('layer_%s_%s', $prefix, $layer->getId()), $layer);
}
break;
default:
throw new LogicException(sprintf('This key is not supported: %s, field %s', $key, $field));
}
}
}
}
}
/**
* @param self::F_* $params
*
* @return array|string[]
*/
public function getKeys(int $params, string $prefix = ''): array
{
$prefixes = [];
foreach (self::ALL as $key => $bitmask) {
if (($params & $bitmask) === $bitmask) {
if ('geographical_units' === $key) {
// geographical unit generate keys dynamically, depending on layers
$prefixes = array_merge($prefixes, array_keys($this->generateKeysForUnitsNames($prefix)), array_keys($this->generateKeysForUnitsRefs($prefix)));
continue;
}
$prefixes = array_merge(
$prefixes,
array_map(
static function ($item) use ($prefix) {
return $prefix . $item;
},
self::COLUMN_MAPPING[$key]
)
);
}
}
return $prefixes;
}
public function getLabel($key, array $values, $data, string $prefix = '', string $translationPrefix = 'export.address_helper.'): callable
{
$sanitizedKey = substr($key, strlen($prefix));
switch ($sanitizedKey) {
case 'id':
case 'street':
case 'streetNumber':
case 'buildingName':
case 'corridor':
case 'distribution':
case 'extra':
case 'flat':
case 'floor':
case '_lat':
case '_lon':
case 'steps':
case 'postcode_code':
case 'postcode_name':
return static function ($value) use ($sanitizedKey, $translationPrefix) {
if ('_header' === $value) {
return $translationPrefix . $sanitizedKey;
}
if (null === $value) {
return '';
}
return $value;
};
case 'country':
return function ($value) use ($key) {
if ('_header' === $value) {
return 'export.list.acp' . $key;
}
if (null === $value) {
return '';
}
return $this->translatableStringHelper->localize(json_decode($value, true));
};
case 'isNoAddress':
case 'confidential':
return static function ($value) use ($sanitizedKey, $translationPrefix) {
if ('_header' === $value) {
return $translationPrefix . $sanitizedKey;
}
switch ($value) {
case null:
return '';
case true:
return 1;
case false:
return 0;
default:
throw new LogicException('this value is not supported for ' . $sanitizedKey . ': ' . $value);
}
};
case '_as_string':
return function ($value) use ($sanitizedKey, $translationPrefix) {
if ('_header' === $value) {
return $translationPrefix . $sanitizedKey;
}
if (null === $value) {
return '';
}
$address = $this->addressRepository->find($value);
return $this->addressRender->renderString($address, []);
};
default:
$layerNamesKeys = array_merge($this->generateKeysForUnitsNames($prefix), $this->generateKeysForUnitsRefs($prefix));
if (array_key_exists($key, $layerNamesKeys)) {
return function ($value) use ($key, $layerNamesKeys) {
if ('_header' === $value) {
$header = $this->translatableStringHelper->localize($layerNamesKeys[$key]->getName());
if (str_contains($key, 'unit_ref')) {
$header .= ' (id)';
}
return $header;
}
if (null === $value) {
return '';
}
$decodedValues = json_decode($value, true);
switch (count($decodedValues)) {
case 0:
return '';
case 1:
return $decodedValues[0];
default:
return implode('|', $decodedValues);
}
};
}
throw new LogicException('this key is not supported: ' . $sanitizedKey);
}
}
/**
* @return array<string, GeographicalUnitLayer>
*/
private function generateKeysForUnitsNames(string $prefix): array
{
if (array_key_exists($prefix, $this->unitNamesKeysCache)) {
return $this->unitNamesKeysCache[$prefix];
}
$keys = [];
foreach ($this->geographicalUnitLayerRepository->findAllHavingUnits() as $layer) {
$keys[$prefix . 'unit_names_' . $layer->getId()] = $layer;
}
return $this->unitNamesKeysCache[$prefix] = $keys;
}
/**
* @return array<string, GeographicalUnitLayer>
*/
private function generateKeysForUnitsRefs(string $prefix): array
{
if (array_key_exists($prefix, $this->unitRefsKeysCache)) {
return $this->unitRefsKeysCache[$prefix];
}
$keys = [];
foreach ($this->geographicalUnitLayerRepository->findAllHavingUnits() as $layer) {
$keys[$prefix . 'unit_refs_' . $layer->getId()] = $layer;
}
return $this->unitRefsKeysCache[$prefix] = $keys;
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export\Helper;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Chill\MainBundle\Templating\Entity\UserRender;
class UserHelper
{
private UserRender $userRender;
private UserRepositoryInterface $userRepository;
public function __construct(UserRender $userRender, UserRepositoryInterface $userRepository)
{
$this->userRender = $userRender;
$this->userRepository = $userRepository;
}
public function getLabel($key, array $values, string $header): callable
{
return function ($value) use ($header) {
if ('_header' === $value) {
return $header;
}
if (null === $value || null === $user = $this->userRepository->find($value)) {
return '';
}
return $this->userRender->renderString($user, []);
};
}
}

View File

@@ -0,0 +1,45 @@
<?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\Service\RollingDate\RollingDate;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\Exception;
class RollingDateDataMapper implements DataMapperInterface
{
public function mapDataToForms($viewData, $forms)
{
if (null === $viewData) {
return;
}
if (!$viewData instanceof RollingDate) {
throw new Exception\UnexpectedTypeException($viewData, RollingDate::class);
}
$forms = iterator_to_array($forms);
$forms['roll']->setData($viewData->getRoll());
$forms['fixedDate']->setData($viewData->getFixedDate());
}
public function mapFormsToData($forms, &$viewData): void
{
$forms = iterator_to_array($forms);
$viewData = new RollingDate(
$forms['roll']->getData(),
$forms['fixedDate']->getData()
);
}
}

View File

@@ -0,0 +1,40 @@
<?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\SavedExport;
use Chill\MainBundle\Form\Type\ChillTextareaType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class SavedExportType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('title', TextType::class, [
'required' => true,
])
->add('description', ChillTextareaType::class, [
'required' => false,
]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'class' => SavedExport::class,
]);
}
}

View File

@@ -51,7 +51,9 @@ class EntityToJsonTransformer implements DataTransformerInterface
}
return array_map(
function ($item) { return $this->denormalizeOne($item); },
function ($item) {
return $this->denormalizeOne($item);
},
$denormalized
);
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form\Type;
use Chill\MainBundle\Form\DataMapper\RollingDateDataMapper;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
class PickRollingDateType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('roll', ChoiceType::class, [
'choices' => array_combine(
array_map(static fn (string $item) => 'rolling_date.' . $item, RollingDate::ALL_T),
RollingDate::ALL_T
),
'multiple' => false,
'expanded' => false,
'label' => 'rolling_date.roll_movement',
])
->add('fixedDate', ChillDateType::class, [
'input' => 'datetime_immutable',
'label' => 'rolling_date.fixed_date_date',
]);
$builder->setDataMapper(new RollingDateDataMapper());
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'class' => RollingDate::class,
'empty_data' => new RollingDate(RollingDate::T_TODAY),
'constraints' => [
new Callback([$this, 'validate']),
],
]);
}
public function validate($data, ExecutionContextInterface $context, $payload): void
{
/** @var RollingDate $data */
if (RollingDate::T_FIXED_DATE === $data->getRoll() && null === $data->getFixedDate()) {
$context
->buildViolation('rolling_date.When fixed date is selected, you must provide a date')
->atPath('fixedDate')
->addViolation();
}
}
public function buildView(FormView $view, FormInterface $form, array $options)
{
$view->vars['uniqid'] = uniqid('rollingdate-');
}
}

View File

@@ -66,7 +66,9 @@ class ScopePickerType extends AbstractType
$options['role'] instanceof Role ? $options['role']->getRole() : $options['role'],
$options['center']
),
static function (Scope $s) { return $s->isActive(); }
static function (Scope $s) {
return $s->isActive();
}
);
if (0 === count($items)) {

View File

@@ -12,19 +12,40 @@ declare(strict_types=1);
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\Civility;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
/**
* @method Civility|null find($id, $lockMode = null, $lockVersion = null)
* @method Civility|null findOneBy(array $criteria, array $orderBy = null)
* @method Civility[] findAll()
* @method Civility[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class CivilityRepository extends ServiceEntityRepository
class CivilityRepository implements CivilityRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
parent::__construct($registry, Civility::class);
$this->repository = $entityManager->getRepository($this->getClassName());
}
public function find($id): ?Civility
{
return $this->repository->find($id);
}
public function findAll(): array
{
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria): ?Civility
{
return $this->repository->findOneBy($criteria);
}
public function getClassName(): string
{
return Civility::class;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\Civility;
use Doctrine\Persistence\ObjectRepository;
interface CivilityRepositoryInterface extends ObjectRepository
{
public function find($id): ?Civility;
/**
* @return array|Civility[]
*/
public function findAll(): array;
/**
* @return array|Civility[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
public function findOneBy(array $criteria): ?Civility;
public function getClassName(): string;
}

View File

@@ -41,7 +41,7 @@ class GeographicalUnitRepository implements GeographicalUnitRepositoryInterface
{
return $this->repository
->createQueryBuilder('gu')
->select('PARTIAL gu.{id,unitName,unitRefId,layer}')
->select(sprintf('NEW %s(gu.id, gu.unitName, gu.unitRefId, IDENTITY(gu.layer))', GeographicalUnit\SimpleGeographicalUnitDTO::class))
->addOrderBy('IDENTITY(gu.layer)')
->addOrderBy(('gu.unitName'))
->getQuery()

View File

@@ -14,15 +14,14 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\Language;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository;
final class LanguageRepository implements ObjectRepository
final class LanguageRepository implements LanguageRepositoryInterface
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
$this->repository = $entityManager->getRepository(Language::class);
$this->repository = $entityManager->getRepository($this->getClassName());
}
public function find($id, $lockMode = null, $lockVersion = null): ?Language
@@ -54,7 +53,7 @@ final class LanguageRepository implements ObjectRepository
return $this->repository->findOneBy($criteria, $orderBy);
}
public function getClassName()
public function getClassName(): string
{
return Language::class;
}

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\MainBundle\Repository;
use Chill\MainBundle\Entity\Language;
use Doctrine\Persistence\ObjectRepository;
interface LanguageRepositoryInterface extends ObjectRepository
{
public function find($id, $lockMode = null, $lockVersion = null): ?Language;
/**
* @return Language[]
*/
public function findAll(): array;
/**
* @param mixed|null $limit
* @param mixed|null $offset
*
* @return Language[]
*/
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array;
public function findOneBy(array $criteria, ?array $orderBy = null): ?Language;
public function getClassName(): string;
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository;
/**
* @implements ObjectRepository<SavedExport>
*/
class SavedExportRepository implements SavedExportRepositoryInterface
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
$this->repository = $entityManager->getRepository($this->getClassName());
}
public function find($id): ?SavedExport
{
return $this->repository->find($id);
}
/**
* @return array|SavedExport[]
*/
public function findAll(): array
{
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findByUser(User $user, ?array $orderBy = [], ?int $limit = null, ?int $offset = null): array
{
$qb = $this->repository->createQueryBuilder('se');
$qb
->where($qb->expr()->eq('se.user', ':user'))
->setParameter('user', $user);
if (null !== $limit) {
$qb->setMaxResults($limit);
}
if (null !== $offset) {
$qb->setFirstResult($offset);
}
foreach ($orderBy as $field => $order) {
$qb->addOrderBy('se.' . $field, $order);
}
return $qb->getQuery()->getResult();
}
public function findOneBy(array $criteria): ?SavedExport
{
return $this->repository->findOneBy($criteria);
}
public function getClassName(): string
{
return SavedExport::class;
}
}

View File

@@ -0,0 +1,40 @@
<?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\SavedExport;
use Chill\MainBundle\Entity\User;
use Doctrine\Persistence\ObjectRepository;
/**
* @implements ObjectRepository<SavedExport>
*/
interface SavedExportRepositoryInterface extends ObjectRepository
{
public function find($id): ?SavedExport;
/**
* @return array|SavedExport[]
*/
public function findAll(): array;
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
/**
* @return array|SavedExport[]
*/
public function findByUser(User $user, ?array $orderBy = [], ?int $limit = null, ?int $offset = null): array;
public function findOneBy(array $criteria): ?SavedExport;
public function getClassName(): string;
}

View File

@@ -518,8 +518,15 @@ div.v-toast {
z-index: 10000!important;
}
div.grouped {
padding: 1em;
border: 1px solid black;
margin-bottom: 2em;
// export index page
div.exports-list {
div.flex-bloc .item-bloc {
flex-basis: 33%;
@include media-breakpoint-down(lg) { flex-basis: 50%; }
@include media-breakpoint-down(sm) { flex-basis: 100%; }
div:last-child,
p:last-child {
margin-top: auto;
}
}
}

View File

@@ -0,0 +1,28 @@
import {ShowHide} from 'ChillMainAssets/lib/show_hide/index';
document.addEventListener('DOMContentLoaded', function(_e) {
console.log('pick-rolling-date');
document.querySelectorAll('div[data-rolling-date]').forEach( (picker) => {
const
roll_wrapper = picker.querySelector('div.roll-wrapper'),
fixed_wrapper = picker.querySelector('div.fixed-wrapper');
new ShowHide({
froms: [roll_wrapper],
container: [fixed_wrapper],
test: function (elems) {
console.log('testing');
console.log('elems', elems);
for (let el of elems) {
for (let select_roll of el.querySelectorAll('select[data-roll-picker]')) {
console.log('select_roll', select_roll);
console.log('value', select_roll.value);
return select_roll.value === 'fixed_date';
}
}
return false;
}
})
});
});

View File

@@ -0,0 +1,12 @@
<ul class="nav nav-pills justify-content-center">
<li class="nav-item">
<a href="{{ chill_path_forward_return_path('chill_main_export_index') }}" class="nav-link {% if current == 'common' %}active{% endif %}">
{{ 'Exports list'|trans }}
</a>
</li>
<li class="nav-item">
<a href="{{ chill_path_forward_return_path('chill_main_export_saved_list_my') }}" class="nav-link {% if current == 'my' %}active{% endif %}">
{{ 'saved_export.My saved exports'|trans }}
</a>
</li>
</ul>

View File

@@ -49,5 +49,14 @@ window.addEventListener("DOMContentLoaded", function(e) {
data-download-text="{{ "Download your report"|trans|escape('html_attr') }}"
><span id="waiting_text">{{ "Waiting for your report"|trans ~ '...' }}</span></div>
</div>
<ul class="record_actions sticky-form-buttons">
<li class="cancel"><a href="{{ chill_return_path_or('chill_main_export_index') }}" class="btn btn-cancel">{{ 'Back to the list'|trans }}</a></li>
{% if not app.request.query.has('prevent_save') %}
<li>
<a href="{{ chill_path_add_return_path('chill_main_export_save_from_key', { alias: alias, key: app.request.query.get('key')}) }}" class="btn btn-save">{{ 'Save'|trans }}</a>
</li>
{% endif %}
</ul>
</div>
{% endblock content %}

View File

@@ -22,23 +22,27 @@
{% block content %}
<div class="col-md-10">
<h1>{{ 'Exports list'|trans }}</h1>
{{ include('@ChillMain/Export/_navbar.html.twig', {'current' : 'common'}) }}
<div class="col-md-10 exports-list">
<div class="container mt-4">
{% for group, exports in grouped_exports %}{% if group != '_' %}
<h2 class="display-6">{{ group|trans }}</h2>
<div class="row grouped">
<div class="row flex-bloc">
{% for export_alias, export in exports %}
<div class="col-6 col-md-4 mb-3">
<h2>{{ export.title|trans }}</h2>
<p>{{ export.description|trans }}</p>
<p>
<a class="btn btn-action" href="{{ path('chill_main_export_new', { 'alias': export_alias } ) }}">
{{ 'Create an export'|trans }}
</a>
</p>
<div class="item-bloc">
<div class="item-row card-body">
<h2 class="card-title">{{ export.title|trans }}</h2>
<p class="card-text my-3">{{ export.description|trans }}</p>
<p>
<a class="btn btn-action" href="{{ path('chill_main_export_new', { 'alias': export_alias } ) }}">
{{ 'Create an export'|trans }}
</a>
</p>
</div>
</div>
{% endfor %}
</div>
@@ -48,17 +52,19 @@
<h2 class="display-6">{{ 'Ungrouped exports'|trans }}</h2>
{% endif %}
<div class="row ungrouped">
<div class="row flex-bloc">
{% for export_alias,export in grouped_exports['_'] %}
<div class="col-6 col-md-4 mb-3">
<h2>{{ export.title|trans }}</h2>
<p>{{ export.description|trans }}</p>
<p>
<a class="btn btn-action" href="{{ path('chill_main_export_new', { 'alias': export_alias } ) }}">
{{ 'Create an export'|trans }}
</a>
</p>
<div class="item-bloc">
<div class="item-row card-body">
<h2 class="card-title">{{ export.title|trans }}</h2>
<p class="card-text my-3">{{ export.description|trans }}</p>
<p>
<a class="btn btn-action" href="{{ path('chill_main_export_new', { 'alias': export_alias } ) }}">
{{ 'Create an export'|trans }}
</a>
</p>
</div>
</div>
{% endfor %}

View File

@@ -22,6 +22,7 @@
{% block css %}
{{ encore_entry_link_tags('mod_pickentity_type') }}
{{ encore_entry_link_tags('mod_pick_rolling_date') }}
{% endblock %}
{% block js %}
@@ -30,6 +31,7 @@
{% if export_alias == 'count_social_work_actions' %}
{{ encore_entry_script_tags('vue_export_action_goal_result') }}
{% endif %}
{{ encore_entry_script_tags('mod_pick_rolling_date') }}
{% endblock js %}
{% block content %}

View File

@@ -257,3 +257,16 @@
<input type="hidden" {{ block('widget_attributes') }} {% if value is not empty %}value="{{ value }}" {% endif %} data-input-uniqid="{{ form.vars['uniqid'] }}"/>
<div data-module="pick-postal-code" data-uniqid="{{ form.vars['uniqid'] }}"></div>
{% endblock %}
{% block pick_rolling_date_widget %}
<div data-rolling-date="{{ form.vars['uniqid'] }}" class="row">
<div class="roll-wrapper col-sm-6">
{{ form_widget(form.roll, { 'attr': { 'data-roll-picker': 'data-roll-picker'}}) }}
{{ form_errors(form.roll) }}
</div>
<div class="fixed-wrapper col-sm-6">
{{ form_widget(form.fixedDate) }}
{{ form_errors(form.fixedDate) }}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block title 'saved_export.Delete saved ?'|trans %}
{% block display_content %}
<div class="col-10">
<h3>{{ saved_export.title }}</h3>
<p>{{ saved_export.description|chill_markdown_to_html }}</p>
</div>
{% endblock %}
{% block content %}
<div class="container chill-md-10">
{{ include('@ChillMain/Util/confirmation_template.html.twig',
{
'title' : 'saved_export.Delete saved ?'|trans,
'confirm_question' : 'saved_export.Are you sure you want to delete this saved ?'|trans,
'display_content' : block('display_content'),
'cancel_route' : 'chill_main_export_saved_list_my',
'cancel_parameters' : {},
'form' : delete_form
} ) }}
</div>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "@ChillMain/layout.html.twig" %}
{% block title %}{{ 'saved_export.Edit'|trans }}{% endblock %}
{% block content %}
<div class="col-10">
<h1>{{ block('title') }}</h1>
{{ form_start(form) }}
{{ form_row(form.title) }}
{{ form_row(form.description) }}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ chill_return_path_or('chill_main_homepage') }}" class="btn btn-cancel">{{ 'Cancel'|trans }}</a>
</li>
<li>
<button type="submit" class="btn btn-save">{{ 'Save'|trans }}</button>
</li>
</ul>
{{ form_end(form) }}
</div>
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% extends "@ChillMain/layout.html.twig" %}
{% block title %}{{ 'saved_export.My saved exports'|trans }}{% endblock %}
{% block content %}
<div class="col-md-10 exports-list">
{{ include('@ChillMain/Export/_navbar.html.twig', {'current' : 'my'}) }}
<div class="container mt-4">
{% if total == 0 %}
<p class="chill-no-data-statement" >{{ 'saved_export.Any saved export'|trans }}</p>
{% endif %}
{% for group, saveds in grouped_exports %}
{% if group != '_' %}
<h2 class="display-6">{{ group }}</h2>
<div class="row flex-bloc">
{% for s in saveds %}
<div class="item-bloc">
<div class="item-row card-body">
<p class="card-subtitle"><strong>{{ s.export.title|trans }}</strong></p>
<h2 class="card-title">{{ s.saved.title }}</h2>
<div class="createdBy">{{ 'saved_export.Created on %date%'|trans({'%date%': s.saved.createdAt|format_datetime('long', 'short')}) }}</div>
<div class="card-text my-3">
{{ s.saved.description|chill_markdown_to_html }}
</div>
<div>
<ul class="record_actions">
<li><a href="{{ chill_path_add_return_path('chill_main_export_saved_delete', {'id': s.saved.id }) }}" class="btn btn-delete"></a></li>
<li><a href="{{ chill_path_add_return_path('chill_main_export_saved_edit', {'id': s.saved.id }) }}" class="btn btn-edit"></a></li>
<li><a href="{{ path('chill_main_export_generate_from_saved', { id: s.saved.id }) }}" class="btn btn-action"><i class="fa fa-cog"></i></a></li>
</ul>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endfor %}
{% if grouped_exports|keys|length > 1 and grouped_exports['_']|default([])|length > 0 %}
<h2 class="display-6">{{ 'Ungrouped exports'|trans }}</h2>
{% endif %}
<div class="row flex-bloc">
{% for saveds in grouped_exports['_']|default([]) %}
{% for s in saveds %}
<div class="item-bloc">
<div class="item-row card-body">
<p class="card-subtitle"><strong>{{ s.export.title|trans }}</strong></p>
<h2 class="card-title">{{ s.saved.title }}</h2>
<div class="createdBy">{{ 'saved_export.Created on %date%'|trans({'%date%': s.saved.createdAt|format_datetime('long', 'short')}) }}</div>
<div class="card-text my-3">
{{ s.saved.description|chill_markdown_to_html }}
</div>
<div>
<ul class="record_actions">
<li><a href="{{ chill_path_add_return_path('chill_main_export_saved_delete', {'id': s.saved.id }) }}" class="btn btn-delete"></a></li>
<li><a href="{{ chill_path_add_return_path('chill_main_export_saved_edit', {'id': s.saved.id }) }}" class="btn btn-edit"></a></li>
<li><a href="{{ path('chill_main_export_generate_from_saved', { id: s.saved.id }) }}" class="btn btn-action"><i class="fa fa-cog"></i></a></li>
</ul>
</div>
</div>
</div>
{% endfor %}
{% endfor %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "@ChillMain/layout.html.twig" %}
{% block title %}{{ 'saved_export.New'|trans }}{% endblock %}
{% block content %}
<div class="col-10">
<h1>{{ block('title') }}</h1>
{{ form_start(form) }}
{{ form_row(form.title) }}
{{ form_row(form.description) }}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ chill_return_path_or('chill_main_homepage') }}" class="btn btn-cancel">{{ 'Cancel'|trans }}</a>
</li>
<li>
<button type="submit" class="btn btn-save">{{ 'Save'|trans }}</button>
</li>
</ul>
{{ form_end(form) }}
</div>
{% endblock %}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Security\Authorization;
use Chill\MainBundle\Entity\SavedExport;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use UnexpectedValueException;
use function in_array;
class SavedExportVoter extends Voter
{
public const DELETE = 'CHLL_MAIN_EXPORT_SAVED_DELETE';
public const EDIT = 'CHLL_MAIN_EXPORT_SAVED_EDIT';
public const GENERATE = 'CHLL_MAIN_EXPORT_SAVED_GENERATE';
private const ALL = [
self::DELETE,
self::EDIT,
self::GENERATE,
];
protected function supports($attribute, $subject): bool
{
return $subject instanceof SavedExport && in_array($attribute, self::ALL, true);
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
/** @var SavedExport $subject */
switch ($attribute) {
case self::DELETE:
case self::EDIT:
case self::GENERATE:
return $subject->getUser() === $token->getUser();
default:
throw new UnexpectedValueException('attribute not supported: ' . $attribute);
}
}
}

View File

@@ -0,0 +1,101 @@
<?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\Service\RollingDate;
use DateTimeImmutable;
class RollingDate
{
public const ALL_T = [
self::T_YEAR_PREVIOUS_START,
self::T_QUARTER_PREVIOUS_START,
self::T_MONTH_PREVIOUS_START,
self::T_WEEK_PREVIOUS_START,
self::T_YEAR_CURRENT_START,
self::T_QUARTER_CURRENT_START,
self::T_MONTH_CURRENT_START,
self::T_WEEK_CURRENT_START,
self::T_TODAY,
self::T_WEEK_NEXT_START,
self::T_MONTH_NEXT_START,
self::T_QUARTER_NEXT_START,
self::T_YEAR_NEXT_START,
self::T_FIXED_DATE,
];
/**
* A given fixed date.
*/
public const T_FIXED_DATE = 'fixed_date';
public const T_MONTH_CURRENT_START = 'month_current_start';
public const T_MONTH_NEXT_START = 'month_next_start';
public const T_MONTH_PREVIOUS_START = 'month_previous_start';
public const T_QUARTER_CURRENT_START = 'quarter_current_start';
public const T_QUARTER_NEXT_START = 'quarter_next_start';
public const T_QUARTER_PREVIOUS_START = 'quarter_previous_start';
public const T_TODAY = 'today';
public const T_WEEK_CURRENT_START = 'week_current_start';
public const T_WEEK_NEXT_START = 'week_next_start';
public const T_WEEK_PREVIOUS_START = 'week_previous_start';
public const T_YEAR_CURRENT_START = 'year_current_start';
public const T_YEAR_NEXT_START = 'year_next_start';
public const T_YEAR_PREVIOUS_START = 'year_previous_start';
private ?DateTimeImmutable $fixedDate;
/**
* The pivot date is the date from the rolling is computed. By default, it is "now".
*/
private DateTimeImmutable $pivotDate;
private string $roll;
/**
* @param string|self::T_* $roll
* @param DateTimeImmutable|null $pivotDate Will be "now" if null is given
* @param DateTimeImmutable|null $fixedDate Only to insert if $roll equals @see{self::T_FIXED_DATE}
*/
public function __construct(string $roll, ?DateTimeImmutable $fixedDate = null, ?DateTimeImmutable $pivotDate = null)
{
$this->roll = $roll;
$this->pivotDate = $pivotDate ?? new DateTimeImmutable('now');
$this->fixedDate = $fixedDate;
}
public function getFixedDate(): ?DateTimeImmutable
{
return $this->fixedDate;
}
public function getPivotDate(): DateTimeImmutable
{
return $this->pivotDate;
}
public function getRoll(): string
{
return $this->roll;
}
}

View File

@@ -0,0 +1,142 @@
<?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\Service\RollingDate;
use DateInterval;
use DateTimeImmutable;
use LogicException;
use UnexpectedValueException;
class RollingDateConverter implements RollingDateConverterInterface
{
public function convert(RollingDate $rollingDate): DateTimeImmutable
{
switch ($rollingDate->getRoll()) {
case RollingDate::T_MONTH_CURRENT_START:
return $this->toBeginOfMonth($rollingDate->getPivotDate());
case RollingDate::T_MONTH_NEXT_START:
return $this->toBeginOfMonth($rollingDate->getPivotDate()->add(new DateInterval('P1M')));
case RollingDate::T_MONTH_PREVIOUS_START:
return $this->toBeginOfMonth($rollingDate->getPivotDate()->sub(new DateInterval('P1M')));
case RollingDate::T_QUARTER_CURRENT_START:
return $this->toBeginOfQuarter($rollingDate->getPivotDate());
case RollingDate::T_QUARTER_NEXT_START:
return $this->toBeginOfQuarter($rollingDate->getPivotDate()->add(new DateInterval('P3M')));
case RollingDate::T_QUARTER_PREVIOUS_START:
return $this->toBeginOfQuarter($rollingDate->getPivotDate()->sub(new DateInterval('P3M')));
case RollingDate::T_WEEK_CURRENT_START:
return $this->toBeginOfWeek($rollingDate->getPivotDate());
case RollingDate::T_WEEK_NEXT_START:
return $this->toBeginOfWeek($rollingDate->getPivotDate()->add(new DateInterval('P1W')));
case RollingDate::T_WEEK_PREVIOUS_START:
return $this->toBeginOfWeek($rollingDate->getPivotDate()->sub(new DateInterval('P1W')));
case RollingDate::T_YEAR_CURRENT_START:
return $this->toBeginOfYear($rollingDate->getPivotDate());
case RollingDate::T_YEAR_PREVIOUS_START:
return $this->toBeginOfYear($rollingDate->getPivotDate()->sub(new DateInterval('P1Y')));
case RollingDate::T_YEAR_NEXT_START:
return $this->toBeginOfYear($rollingDate->getPivotDate()->add(new DateInterval('P1Y')));
case RollingDate::T_TODAY:
return $rollingDate->getPivotDate();
case RollingDate::T_FIXED_DATE:
if (null === $rollingDate->getFixedDate()) {
throw new LogicException('You must provide a fixed date when selecting a fixed date');
}
return $rollingDate->getFixedDate();
default:
throw new UnexpectedValueException(sprintf('%s rolling operation not supported', $rollingDate->getRoll()));
}
}
private function toBeginOfMonth(DateTimeImmutable $date): DateTimeImmutable
{
return DateTimeImmutable::createFromFormat(
'Y-m-d His',
sprintf('%s-%s-01 000000', $date->format('Y'), $date->format('m'))
);
}
private function toBeginOfQuarter(DateTimeImmutable $date): DateTimeImmutable
{
switch ((int) $date->format('n')) {
case 1:
case 2:
case 3:
$month = '01';
break;
case 4:
case 5:
case 6:
$month = '04';
break;
case 7:
case 8:
case 9:
$month = '07';
break;
case 10:
case 11:
case 12:
$month = '10';
break;
default:
throw new LogicException('this month is not valid: ' . $date->format('n'));
}
return DateTimeImmutable::createFromFormat(
'Y-m-d His',
sprintf('%s-%s-01 000000', $date->format('Y'), $month)
);
}
private function toBeginOfWeek(DateTimeImmutable $date): DateTimeImmutable
{
if (1 === $dayOfWeek = (int) $date->format('N')) {
return $date->setTime(0, 0, 0);
}
return $date
->sub(new DateInterval('P' . ($dayOfWeek - 1) . 'D'))
->setTime(0, 0, 0);
}
private function toBeginOfYear(DateTimeImmutable $date): DateTimeImmutable
{
return DateTimeImmutable::createFromFormat(
'Y-m-d His',
sprintf('%s-01-01 000000', $date->format('Y'))
);
}
}

View File

@@ -0,0 +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\MainBundle\Service\RollingDate;
use DateTimeImmutable;
interface RollingDateConverterInterface
{
public function convert(RollingDate $rollingDate): DateTimeImmutable;
}

View File

@@ -0,0 +1,48 @@
<?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\Tests\Doctrine\DQL;
use Chill\MainBundle\Entity\Address;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
* @coversNothing
*/
final class GreatestTest extends KernelTestCase
{
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
self::bootKernel();
$this->entityManager = self::$container->get(EntityManagerInterface::class);
}
public function testGreatestInDQL()
{
$dql = 'SELECT GREATEST(a.validFrom, a.validTo, :now) AS g FROM ' . Address::class . ' a WHERE a.validTo < :now and a.validFrom < :now';
$actual = $this->entityManager
->createQuery($dql)
->setParameter('now', $now = new DateTimeImmutable('now'), Types::DATE_IMMUTABLE)
->setMaxResults(3)
->getResult();
$this->assertIsArray($actual);
$this->assertEquals($now->format('Y-m-d'), $actual[0]['g']);
}
}

View File

@@ -0,0 +1,48 @@
<?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\Tests\Doctrine\DQL;
use Chill\MainBundle\Entity\Address;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
* @coversNothing
*/
final class LeastTest extends KernelTestCase
{
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
self::bootKernel();
$this->entityManager = self::$container->get(EntityManagerInterface::class);
}
public function testGreatestInDQL()
{
$dql = 'SELECT LEAST(a.validFrom, a.validTo, :now) AS g FROM ' . Address::class . ' a WHERE a.validTo < :now and a.validFrom < :now';
$actual = $this->entityManager
->createQuery($dql)
->setParameter('now', $now = new DateTimeImmutable('now'), Types::DATE_IMMUTABLE)
->setMaxResults(3)
->getResult();
$this->assertIsArray($actual);
$this->assertNotEquals($now->format('Y-m-d'), $actual[0]['g']);
}
}

View File

@@ -134,7 +134,9 @@ final class NotificationTest extends KernelTestCase
$this->assertEquals($senderId, $notification->getSender()->getId());
$this->assertCount(count($addressesIds), $notification->getUnreadBy());
$unreadIds = $notification->getUnreadBy()->map(static function (User $u) { return $u->getId(); });
$unreadIds = $notification->getUnreadBy()->map(static function (User $u) {
return $u->getId();
});
foreach ($addressesIds as $addresseeId) {
$this->assertContains($addresseeId, $unreadIds);

View File

@@ -680,10 +680,12 @@ final class ExportManagerTest extends KernelTestCase
return new ExportManager(
$logger ?? self::$container->get('logger'),
$em ?? self::$container->get('doctrine.orm.entity_manager'),
$authorizationChecker ?? self::$container->get('security.authorization_checker'),
$authorizationHelper ?? self::$container->get('chill.main.security.authorization.helper'),
$tokenStorage
$tokenStorage,
[],
[],
[]
);
}
}

View File

@@ -0,0 +1,53 @@
<?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 Form\Type;
use Chill\MainBundle\Form\Type\PickRollingDateType;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use Symfony\Component\Form\PreloadedExtension;
use Symfony\Component\Form\Test\TypeTestCase;
/**
* @internal
* @coversNothing
*/
final class PickRollingDateTypeTest extends TypeTestCase
{
public function testSubmitValidData()
{
$formData = [
'roll' => 'year_previous_start',
'fixedDate' => null,
];
$form = $this->factory->create(PickRollingDateType::class);
$form->submit($formData);
$this->assertTrue($form->isSynchronized());
/** @var RollingDate $rollingDate */
$rollingDate = $form->getData();
$this->assertInstanceOf(RollingDate::class, $rollingDate);
$this->assertEquals(RollingDate::T_YEAR_PREVIOUS_START, $rollingDate->getRoll());
}
protected function getExtensions(): array
{
$type = new PickRollingDateType();
return [
new PreloadedExtension([$type], []),
];
}
}

View File

@@ -96,7 +96,9 @@ final class ScopePickerTypeTest extends TypeTestCase
$translatableStringHelper = $this->prophesize(TranslatableStringHelperInterface::class);
$translatableStringHelper->localize(Argument::type('array'))->will(
static function ($args) { return $args[0]['fr']; }
static function ($args) {
return $args[0]['fr'];
}
);
$type = new ScopePickerType(

View File

@@ -211,7 +211,9 @@ final class AuthorizationHelperTest extends KernelTestCase
$centerA
);
$usernames = array_map(static function (User $u) { return $u->getUsername(); }, $users);
$usernames = array_map(static function (User $u) {
return $u->getUsername();
}, $users);
$this->assertContains('center a_social', $usernames);
}

View File

@@ -0,0 +1,101 @@
<?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 Services\RollingDate;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use Chill\MainBundle\Service\RollingDate\RollingDateConverter;
use DateTime;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
/**
* @internal
* @coversNothing
*/
final class RollingDateConverterTest extends TestCase
{
private RollingDateConverter $converter;
protected function setUp(): void
{
$this->converter = new RollingDateConverter();
}
public function generateDataConversionDate(): iterable
{
$format = 'Y-m-d His';
yield [RollingDate::T_MONTH_CURRENT_START, '2022-11-01 000000', $format];
yield [RollingDate::T_MONTH_NEXT_START, '2022-12-01 000000', $format];
yield [RollingDate::T_MONTH_PREVIOUS_START, '2022-10-01 000000', $format];
yield [RollingDate::T_QUARTER_CURRENT_START, '2022-10-01 000000', $format];
yield [RollingDate::T_QUARTER_NEXT_START, '2023-01-01 000000', $format];
yield [RollingDate::T_QUARTER_PREVIOUS_START, '2022-07-01 000000', $format];
yield [RollingDate::T_TODAY, '2022-11-07 000000', $format];
yield [RollingDate::T_WEEK_CURRENT_START, '2022-11-07 000000', $format];
yield [RollingDate::T_WEEK_NEXT_START, '2022-11-14 000000', $format];
yield [RollingDate::T_WEEK_PREVIOUS_START, '2022-10-31 000000', $format];
yield [RollingDate::T_YEAR_CURRENT_START, '2022-01-01 000000', $format];
yield [RollingDate::T_YEAR_NEXT_START, '2023-01-01 000000', $format];
yield [RollingDate::T_YEAR_PREVIOUS_START, '2021-01-01 000000', $format];
}
public function testConversionFixedDate()
{
$rollingDate = new RollingDate(RollingDate::T_FIXED_DATE, new DateTimeImmutable('2022-01-01'));
$this->assertEquals(
'2022-01-01',
$this->converter->convert($rollingDate)->format('Y-m-d')
);
}
public function testConvertOnDateNow()
{
$rollingDate = new RollingDate(RollingDate::T_YEAR_PREVIOUS_START);
$actual = $this->converter->convert($rollingDate);
$this->assertEquals(
(int) (new DateTimeImmutable('now'))->format('Y') - 1,
(int) $actual->format('Y')
);
$this->assertEquals(1, (int) $actual->format('m'));
$this->assertEquals(1, (int) $actual->format('d'));
}
/**
* @dataProvider generateDataConversionDate
*/
public function testConvertOnPivotDate(string $roll, string $expectedDateTime, string $format)
{
$pivot = DateTimeImmutable::createFromFormat('Y-m-d His', '2022-11-07 000000');
$rollingDate = new RollingDate($roll, null, $pivot);
$this->assertEquals(
DateTime::createFromFormat($format, $expectedDateTime),
$this->converter->convert($rollingDate)
);
}
}

View File

@@ -107,7 +107,9 @@ class NotificationOnTransition implements EventSubscriberInterface
'dest' => $subscriber,
'place' => $place,
'workflow' => $workflow,
'is_dest' => in_array($subscriber->getId(), array_map(static function (User $u) { return $u->getId(); }, $entityWorkflow->futureDestUsers), true),
'is_dest' => in_array($subscriber->getId(), array_map(static function (User $u) {
return $u->getId();
}, $entityWorkflow->futureDestUsers), true),
];
$notification = new Notification();

View File

@@ -71,6 +71,7 @@ module.exports = function(encore, entries)
encore.addEntry('mod_entity_workflow_pick', __dirname + '/Resources/public/module/entity-workflow-pick/index.js');
encore.addEntry('mod_wopi_link', __dirname + '/Resources/public/module/wopi-link/index.js');
encore.addEntry('mod_pick_postal_code', __dirname + '/Resources/public/module/pick-postal-code/index.js');
encore.addEntry('mod_pick_rolling_date', __dirname + '/Resources/public/module/pick-rolling-date/index.js');
// Vue entrypoints
encore.addEntry('vue_address', __dirname + '/Resources/public/vuejs/Address/index.js');

View File

@@ -106,3 +106,8 @@ services:
resource: '../Service/Import/'
autowire: true
autoconfigure: true
Chill\MainBundle\Service\RollingDate\:
resource: '../Service/RollingDate/'
autowire: true
autoconfigure: true

View File

@@ -3,6 +3,9 @@ services:
autowire: true
autoconfigure: true
Chill\MainBundle\Export\Helper\:
resource: '../../Export/Helper'
chill.main.export_element_validator:
class: Chill\MainBundle\Validator\Constraints\Export\ExportElementConstraintValidator
tags:

View File

@@ -50,6 +50,8 @@ services:
tags:
- { name: security.voter }
Chill\MainBundle\Security\Authorization\SavedExportVoter: ~
Chill\MainBundle\Security\PasswordRecover\TokenManager:
arguments:
$secret: '%kernel.secret%'

View File

@@ -0,0 +1,43 @@
<?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;
final class Version20221107212201 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE chill_main_saved_export');
}
public function getDescription(): string
{
return 'Create table for saved exports';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE chill_main_saved_export (id UUID NOT NULL, user_id INT DEFAULT NULL, description TEXT DEFAULT \'\' NOT NULL, exportAlias TEXT DEFAULT \'\' NOT NULL, options JSONB DEFAULT \'[]\' NOT NULL, title TEXT DEFAULT \'\' NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, createdBy_id INT DEFAULT NULL, updatedBy_id INT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_C2029B22A76ED395 ON chill_main_saved_export (user_id)');
$this->addSql('CREATE INDEX IDX_C2029B223174800F ON chill_main_saved_export (createdBy_id)');
$this->addSql('CREATE INDEX IDX_C2029B2265FF1AEC ON chill_main_saved_export (updatedBy_id)');
$this->addSql('COMMENT ON COLUMN chill_main_saved_export.id IS \'(DC2Type:uuid)\'');
$this->addSql('COMMENT ON COLUMN chill_main_saved_export.options IS \'(DC2Type:json)\'');
$this->addSql('COMMENT ON COLUMN chill_main_saved_export.createdAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_main_saved_export.updatedAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE chill_main_saved_export ADD CONSTRAINT FK_C2029B22A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_main_saved_export ADD CONSTRAINT FK_C2029B223174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_main_saved_export ADD CONSTRAINT FK_C2029B2265FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
}

View File

@@ -0,0 +1,42 @@
<?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;
final class Version20221114105345 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->addSql('DROP MATERIALIZED VIEW view_chill_main_address_geographical_unit');
}
public function getDescription(): string
{
return 'Create materialized view to store GeographicalUnitAddress';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE MATERIALIZED VIEW view_chill_main_address_geographical_unit (address_id, geographical_unit_id) AS
SELECT
address.id AS address_id,
geographical_unit.id AS geographical_unit_id
FROM chill_main_address AS address
JOIN chill_main_geographical_unit AS geographical_unit ON ST_CONTAINS(geographical_unit.geom, address.point)
');
$this->addSql('CREATE INDEX IDX_BD42692CF5B7AF75 ON view_chill_main_address_geographical_unit (address_id)');
$this->addSql('CREATE INDEX IDX_BD42692CDAA4DAB8 ON view_chill_main_address_geographical_unit (geographical_unit_id)');
}
}

View File

@@ -39,6 +39,8 @@ Last updated by: Dernière mise à jour par
on: "le "
Last updated on: Dernière mise à jour le
by_user: "par "
lifecycleUpdate: Evenements de création et mise à jour
address_fields: Données liées à l'adresse
Edit: Modifier
Update: Mettre à jour
@@ -57,6 +59,7 @@ Until: Jusqu'au
#elements used in software
centers: centres
Centers: Centres
center: centre
comment: commentaire
Comment: Commentaire
Pinned comment: Commentaire épinglé
@@ -79,17 +82,17 @@ Postal code: Code postal
Valid from: Valide à partir du
Choose a postal code: Choisir un code postal
address:
address_homeless: L'adresse est-elle celle d'un domicile fixe ?
real address: Adresse d'un domicile
consider homeless: Cette adresse est incomplète
address_homeless: L'adresse est-elle celle d'un domicile fixe ?
real address: Adresse d'un domicile
consider homeless: Cette adresse est incomplète
address more:
floor: ét
corridor: coul
steps: esc
flat: appart
buildingName: résidence
extra: ""
distribution: cedex
floor: ét
corridor: coul
steps: esc
flat: appart
buildingName: résidence
extra: ""
distribution: cedex
Create a new address: Créer une nouvelle adresse
Create an address: Créer une adresse
Update address: Modifier l'adresse
@@ -125,7 +128,7 @@ Location and location type: Localisations et types de localisation
Back to the admin: Menu d'administration
"Administration interface": Interface d'administration
Welcome to the admin section !: >
Bienvenue dans l'interface d'administration !
Bienvenue dans l'interface d'administration !
#permissions
Permissions Menu: Gestion des droits
@@ -236,6 +239,7 @@ Default for: Type de localisation par défaut pour
none: aucun
person: usager
thirdparty: tiers
civility: civilité
#admin section for civility
abbreviation: abbréviation
@@ -334,69 +338,69 @@ Impersonate: Incarner l'utilisateur
Impersonate mode: Mode fantôme
crud:
# general items
new:
button_action_form: Créer
link_edit: Modifier
save_and_close: Créer & fermer
save_and_show: Créer & voir
save_and_new: Créer & nouveau
success: Les données ont été créées
edit:
button_action_form: Enregistrer
back_to_view: Voir
save_and_close: Enregistrer & fermer
save_and_show: Enregistrer & voir
success: Les données ont été modifiées
delete:
success: Les données ont été supprimées
link_to_form: Supprimer
default:
success: Les données ont été enregistrées
view:
link_duplicate: Dupliquer
admin_user:
index:
title: Utilisateurs
add_new: Créer
title_edit: Modifier un utilisateur
title_new: Créer un utilisateur
admin_user_job:
index:
title: Métiers
add_new: Créer
title_new: Nouveau métier
title_edit: Modifier un métier
main_location_type:
index:
title: Liste des types de localisations
add_new: Ajouter un type de localisation
title_new: Nouveau type de localisation
title_edit: Modifier un type de localisation
main_location:
index:
title: Liste des localisations
add_new: Ajouter une localisation
title_new: Nouvelle localisation
title_edit: Modifier une localisation
main_language:
index:
title: Liste des langues
add_new: Ajouter une langue
title_new: Nouvelle langue
title_edit: Modifier une langue
main_country:
index:
title: Liste des pays
add_new: Ajouter un pays
title_new: Nouveau pays
title_edit: Modifier un pays
main_civility:
index:
title: Liste des civilités
add_new: Ajouter une civilité
title_new: Nouvelle civilité
title_edit: Modifier une civilité
# general items
new:
button_action_form: Créer
link_edit: Modifier
save_and_close: Créer & fermer
save_and_show: Créer & voir
save_and_new: Créer & nouveau
success: Les données ont été créées
edit:
button_action_form: Enregistrer
back_to_view: Voir
save_and_close: Enregistrer & fermer
save_and_show: Enregistrer & voir
success: Les données ont été modifiées
delete:
success: Les données ont été supprimées
link_to_form: Supprimer
default:
success: Les données ont été enregistrées
view:
link_duplicate: Dupliquer
admin_user:
index:
title: Utilisateurs
add_new: Créer
title_edit: Modifier un utilisateur
title_new: Créer un utilisateur
admin_user_job:
index:
title: Métiers
add_new: Créer
title_new: Nouveau métier
title_edit: Modifier un métier
main_location_type:
index:
title: Liste des types de localisations
add_new: Ajouter un type de localisation
title_new: Nouveau type de localisation
title_edit: Modifier un type de localisation
main_location:
index:
title: Liste des localisations
add_new: Ajouter une localisation
title_new: Nouvelle localisation
title_edit: Modifier une localisation
main_language:
index:
title: Liste des langues
add_new: Ajouter une langue
title_new: Nouvelle langue
title_edit: Modifier une langue
main_country:
index:
title: Liste des pays
add_new: Ajouter un pays
title_new: Nouveau pays
title_edit: Modifier un pays
main_civility:
index:
title: Liste des civilités
add_new: Ajouter une civilité
title_new: Nouvelle civilité
title_edit: Modifier une civilité
No entities: Aucun élément
@@ -515,3 +519,51 @@ notification:
Remove an email: Supprimer l'adresse email
Email with access link: Adresse email ayant reçu un lien d'accès
export:
address_helper:
id: Identifiant de l'adresse
street: Voie
streetNumber: Numéro de voie
buildingName: Résidence
corridor: Couloir
distribution: Distribution
extra: Extra
flat: Appartement
floor: Étage
postcode_code: Code postal
postcode_name: Libellé du code postal
country: Pays
_as_string: Adresse formattée
confidential: Adresse confidentielle ?
isNoAddress: Adresse incomplète ?
_lat: Latitude
_lon: Longitude
rolling_date:
year_previous_start: Début de l'année précédente
quarter_previous_start: Début du trimestre précédent
month_previous_start: Début du mois précédent
week_previous_start: Début de la semaine précédente
year_current_start: Début de l'année courante
quarter_current_start: Début du trimestre courant
month_current_start: Début du mois courant
week_current_start: Début de la semaine courante
today: Aujourd'hui (aucune modification de la date courante)
year_next_start: Début de l'année suivante
quarter_next_start: Début du trimestre suivante
month_next_start: Début du mois suivant
week_next_start: Début de la semaine suivante
fixed_date: Date fixe
roll_movement: Modification par rapport à aujourd'hui
fixed_date_date: Date fixe
saved_export:
Any saved export: Aucun export enregistré
New: Nouvel export enregistré
Edit: Modifier un export enregistré
Delete saved ?: Supprimer un export enregistré ?
Are you sure you want to delete this saved ?: Êtes-vous sûr·e de vouloir supprimer cet export ?
My saved exports: Mes exports enregistrés
Export is deleted: L'export est supprimé
Saved export is saved!: L'export est enregistré
Created on %date%: Créé le %date%

View File

@@ -31,3 +31,6 @@ notification:
workflow:
You must add at least one dest user or email: Indiquez au moins un destinataire ou une adresse email
rolling_date:
When fixed date is selected, you must provide a date: Indiquez la date fixe choisie