Merge remote-tracking branch 'origin/rector/rules-symfony' into rector/rules-symfony

This commit is contained in:
2023-10-16 18:07:42 +02:00
116 changed files with 3454 additions and 1032 deletions

View File

@@ -50,7 +50,7 @@ final readonly class UserExportController
fn (string $e) => $this->translator->trans('admin.users.export.' . $e),
[
'id',
'username',
// 'username',
'email',
'enabled',
'civility_id',

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\Controller;
use Chill\MainBundle\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Twig\Environment;
class UserJobScopeHistoriesController extends AbstractController
{
public function __construct(
private readonly Environment $engine,
) {}
/**
* @Route("/{_locale}/admin/main/user/{id}/job-scope-history", name="admin_user_job_scope_history")
*/
public function indexAction(User $user): Response
{
$jobHistories = $user->getUserJobHistoriesOrdered();
$scopeHistories = $user->getMainScopeHistoriesOrdered();
return new Response(
$this->engine->render(
'@ChillMain/User/history.html.twig',
[
'user' => $user,
'jobHistories' => $jobHistories,
'scopeHistories' => $scopeHistories,
]
)
);
}
}

View File

@@ -23,6 +23,8 @@ use Chill\MainBundle\Controller\RegroupmentController;
use Chill\MainBundle\Controller\UserController;
use Chill\MainBundle\Controller\UserJobApiController;
use Chill\MainBundle\Controller\UserJobController;
use Chill\MainBundle\Controller\UserJobHistoryController;
use Chill\MainBundle\Controller\UserScopeHistoryController;
use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface;
use Chill\MainBundle\Doctrine\DQL\Age;
use Chill\MainBundle\Doctrine\DQL\Extract;
@@ -31,6 +33,7 @@ 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\JsonBuildObject;
use Chill\MainBundle\Doctrine\DQL\JsonExtract;
use Chill\MainBundle\Doctrine\DQL\Least;
use Chill\MainBundle\Doctrine\DQL\OverlapsI;
@@ -56,6 +59,8 @@ use Chill\MainBundle\Entity\Regroupment;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Form\CenterType;
use Chill\MainBundle\Entity\User\UserJobHistory;
use Chill\MainBundle\Entity\User\UserScopeHistory;
use Chill\MainBundle\Form\CivilityType;
use Chill\MainBundle\Form\CountryType;
use Chill\MainBundle\Form\LanguageType;
@@ -251,6 +256,7 @@ class ChillMainExtension extends Extension implements
'AGGREGATE' => JsonAggregate::class,
'REPLACE' => Replace::class,
'JSON_EXTRACT' => JsonExtract::class,
'JSON_BUILD_OBJECT' => JsonBuildObject::class,
],
'numeric_functions' => [
'JSONB_EXISTS_IN_ARRAY' => JsonbExistsInArray::class,

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 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;
/**
* Return an aggregation of values in a json representation, as a string.
*
* Internally, this function use the postgresql `jsonb_agg` function. Using
* json allow to aggregate data from different types without have to cast them.
*/
class JsonBuildObject extends FunctionNode
{
/**
* @var array|Node[]
*/
private array $exprs = [];
public function getSql(SqlWalker $sqlWalker)
{
return 'JSONB_BUILD_OBJECT(' . implode(', ', array_map(static fn (Node $expr) => $expr->dispatch($sqlWalker), $this->exprs)) . ')';
}
public function parse(Parser $parser)
{
$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

@@ -11,10 +11,15 @@ declare(strict_types=1);
namespace Chill\MainBundle\Entity;
use Chill\MainBundle\Entity\User\UserJobHistory;
use Chill\MainBundle\Entity\User\UserScopeHistory;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping as ORM;
use Iterator;
use RuntimeException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation as Serializer;
@@ -113,9 +118,11 @@ class User implements UserInterface, \Stringable
private ?Location $mainLocation = null;
/**
* @ORM\ManyToOne(targetEntity=Scope::class)
* @var Collection&Selectable<int, UserScopeHistory>
* @ORM\OneToMany(targetEntity=UserScopeHistory::class,
* mappedBy="user", cascade={"persist", "remove"}, orphanRemoval=true)
*/
private ?Scope $mainScope = null;
private Collection&Selectable $scopeHistories;
/**
* @ORM\Column(type="string", length=255)
@@ -130,9 +137,11 @@ class User implements UserInterface, \Stringable
private ?string $salt = null;
/**
* @ORM\ManyToOne(targetEntity=UserJob::class)
* @var Collection&Selectable<int, UserJobHistory>
* @ORM\OneToMany(targetEntity=UserJobHistory::class,
* mappedBy="user", cascade={"persist", "remove"}, orphanRemoval=true)
*/
private ?UserJob $userJob = null;
private Collection&Selectable $jobHistories;
/**
* @ORM\Column(type="string", length=80)
@@ -154,6 +163,8 @@ class User implements UserInterface, \Stringable
public function __construct()
{
$this->groupCenters = new ArrayCollection();
$this->scopeHistories = new ArrayCollection();
$this->jobHistories = new ArrayCollection();
}
/**
@@ -252,9 +263,38 @@ class User implements UserInterface, \Stringable
return $this->mainLocation;
}
public function getMainScope(): ?Scope
public function getMainScope(?DateTimeImmutable $at = null): ?Scope
{
return $this->mainScope;
$at ??= new DateTimeImmutable('now');
foreach ($this->scopeHistories as $scopeHistory) {
if ($at >= $scopeHistory->getStartDate() && (
null === $scopeHistory->getEndDate() || $at < $scopeHistory->getEndDate()
)) {
return $scopeHistory->getScope();
}
}
return null;
}
public function getMainScopeHistories(): Collection
{
return $this->scopeHistories;
}
/**
* @return ArrayCollection|UserScopeHistory[]
*/
public function getMainScopeHistoriesOrdered(): ArrayCollection
{
$scopeHistories = $this->getMainScopeHistories();
$sortedScopeHistories = $scopeHistories->toArray();
usort($sortedScopeHistories, fn ($a, $b) => $a->getStartDate() < $b->getStartDate() ? 1 : -1);
return new ArrayCollection($sortedScopeHistories);
}
/**
@@ -275,9 +315,38 @@ class User implements UserInterface, \Stringable
return $this->salt;
}
public function getUserJob(): ?UserJob
public function getUserJob(?DateTimeImmutable $at = null): ?UserJob
{
return $this->userJob;
$at ??= new DateTimeImmutable('now');
foreach ($this->jobHistories as $jobHistory) {
if ($at >= $jobHistory->getStartDate() && (
null === $jobHistory->getEndDate() || $at < $jobHistory->getEndDate()
)) {
return $jobHistory->getJob();
}
}
return null;
}
public function getUserJobHistories(): Collection
{
return $this->jobHistories;
}
/**
* @return ArrayCollection|UserJobHistory[]
*/
public function getUserJobHistoriesOrdered(): ArrayCollection
{
$jobHistories = $this->getUserJobHistories();
$sortedJobHistories = $jobHistories->toArray();
usort($sortedJobHistories, fn ($a, $b) => $a->getStartDate() < $b->getStartDate() ? 1 : -1);
return new ArrayCollection($sortedJobHistories);
}
/**
@@ -455,7 +524,36 @@ class User implements UserInterface, \Stringable
public function setMainScope(?Scope $mainScope): User
{
$this->mainScope = $mainScope;
if ($mainScope === $this->getMainScope()) {
return $this;
}
$newScope = new UserScopeHistory();
$newScope
->setStartDate(new DateTimeImmutable('now'))
->setScope($mainScope)
->setUser($this);
$this->scopeHistories[] = $newScope;
$criteria = new Criteria();
$criteria->orderBy(['startDate' => Criteria::ASC, 'id' => Criteria::ASC]);
/** @var Iterator $scopes */
$scopes = $this->scopeHistories->matching($criteria)->getIterator();
$scopes->rewind();
do {
/** @var UserScopeHistory $current */
$current = $scopes->current();
$scopes->next();
if ($scopes->valid()) {
$next = $scopes->current();
$current->setEndDate($next->getStartDate());
}
} while ($scopes->valid());
return $this;
}
@@ -486,7 +584,36 @@ class User implements UserInterface, \Stringable
public function setUserJob(?UserJob $userJob): User
{
$this->userJob = $userJob;
if ($userJob === $this->getUserJob()) {
return $this;
}
$newJob = new UserJobHistory();
$newJob
->setStartDate(new DateTimeImmutable('now'))
->setJob($userJob)
->setUser($this);
$this->jobHistories[] = $newJob;
$criteria = new Criteria();
$criteria->orderBy(['startDate' => Criteria::ASC, 'id' => Criteria::ASC]);
/** @var Iterator $jobs */
$jobs = $this->jobHistories->matching($criteria)->getIterator();
$jobs->rewind();
do {
/** @var UserJobHistory $current */
$current = $jobs->current();
$jobs->next();
if ($jobs->valid()) {
$next = $jobs->current();
$current->setEndDate($next->getStartDate());
}
} while ($jobs->valid());
return $this;
}

View File

@@ -0,0 +1,107 @@
<?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\User;
use App\Repository\UserJobHistoryRepository;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserJob;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Table(name="chill_main_user_job_history")
* @ORM\Entity(repositoryClass=UserJobHistoryRepository::class)
*/
class UserJobHistory
{
/**
* @ORM\Column(type="datetime_immutable", nullable=true)
*/
private ?DateTimeImmutable $endDate = null;
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private ?int $id = null;
/**
* @ORM\ManyToOne(targetEntity=UserJob::class)
*/
private ?UserJob $job = null;
/**
* @ORM\Column(type="datetime_immutable")
*/
private DateTimeImmutable $startDate;
/**
* @ORM\ManyToOne(targetEntity=User::class)
*/
private User $user;
public function getEndDate(): ?DateTimeImmutable
{
return $this->endDate;
}
public function getId(): ?int
{
return $this->id;
}
public function getJob(): ?UserJob
{
return $this->job;
}
public function getStartDate(): ?DateTimeImmutable
{
return $this->startDate;
}
public function getUser(): User
{
return $this->user;
}
public function setEndDate(?DateTimeImmutable $endDate): self
{
$this->endDate = $endDate;
return $this;
}
public function setJob(?UserJob $job): UserJobHistory
{
$this->job = $job;
return $this;
}
public function setStartDate(DateTimeImmutable $startDate): self
{
$this->startDate = $startDate;
return $this;
}
public function setUser(User $user): UserJobHistory
{
$this->user = $user;
return $this;
}
}

View File

@@ -0,0 +1,107 @@
<?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\User;
use App\Repository\UserScopeHistoryRepository;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Table(name="chill_main_user_scope_history")
* @ORM\Entity(repositoryClass=UserScopeHistoryRepository::class)
*/
class UserScopeHistory
{
/**
* @ORM\Column(type="datetime_immutable", nullable=true)
*/
private ?DateTimeImmutable $endDate = null;
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private ?int $id = null;
/**
* @ORM\ManyToOne(targetEntity=Scope::class)
*/
private ?Scope $scope = null;
/**
* @ORM\Column(type="datetime_immutable")
*/
private DateTimeImmutable $startDate;
/**
* @ORM\ManyToOne(targetEntity=User::class)
*/
private User $user;
public function getEndDate(): ?DateTimeImmutable
{
return $this->endDate;
}
public function getId(): ?int
{
return $this->id;
}
public function getScope(): ?Scope
{
return $this->scope;
}
public function getStartDate(): ?DateTimeImmutable
{
return $this->startDate;
}
public function getUser(): User
{
return $this->user;
}
public function setEndDate(?DateTimeImmutable $endDate): self
{
$this->endDate = $endDate;
return $this;
}
public function setScope(?Scope $scope): UserScopeHistory
{
$this->scope = $scope;
return $this;
}
public function setStartDate(DateTimeImmutable $startDate): self
{
$this->startDate = $startDate;
return $this;
}
public function setUser(User $user): UserScopeHistory
{
$this->user = $user;
return $this;
}
}

View File

@@ -281,9 +281,9 @@ class ExportAddressHelper
};
case 'country':
return function ($value) use ($key) {
return function ($value) use ($sanitizedKey, $translationPrefix) {
if ('_header' === $value) {
return 'export.list.acp' . $key;
return $translationPrefix . $sanitizedKey;
}
if (null === $value) {

View File

@@ -20,21 +20,64 @@ class UserHelper
{
public function __construct(private readonly UserRender $userRender, private readonly UserRepositoryInterface $userRepository) {}
/**
* Return a callable that will transform a value into a string representing a user
*
* The callable may receive as argument:
*
* - an int or a string, the id of the user;
* - a string containing a json which will be decoded, and will have this structure: array{uid: int, d: string}. The job and scopes will be shown at this date
*
* @param string $key the key of the content
* @param array $values the list of values
* @param string $header the header's content
*/
public function getLabel($key, array $values, string $header): callable
{
return function ($value) use ($header) {
return function (null|int|string $value) use ($header) {
if ('_header' === $value) {
return $header;
}
if (null === $value || null === $user = $this->userRepository->find($value)) {
if (null === $value) {
return '';
}
return $this->userRender->renderString($user, []);
if (is_numeric($value)) {
$uid = $value;
$date = null;
} else {
$decode = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
$uid = $decode['uid'];
if (null === $uid) {
return '';
}
$date = new \DateTimeImmutable($decode['d']);
}
if (null === $user = $this->userRepository->find($uid)) {
return '';
}
return $this->userRender->renderString($user, ['at' => $date]);
};
}
/**
* Return a callable that will transform a value into a string representing a user
*
* The callable may receive as argument:
*
* - an int or a string, the id of the user;
* - a string containing a json which will be decoded, and will have this structure: array{uid: int, d: string}. The job and scopes will be shown at this date * @param $key
*
* @param string $key the key of the element
* @param array $values a list of values
* @param string $header the header's content
* @return callable
*/
public function getLabelMulti($key, array $values, string $header): callable
{
return function ($value) use ($header) {
@@ -46,31 +89,36 @@ class UserHelper
return '';
}
$decoded = json_decode((string) $value, null, 512, JSON_THROW_ON_ERROR);
$decoded = json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR);
if (0 === count($decoded)) {
return '';
}
$asStrings = [];
return
implode(
'|',
array_map(
function (int $userId) {
$user = $this->userRepository->find($userId);
foreach ($decoded as $userId) {
if (is_array($userId)) {
$uid = $userId['uid'];
$date = new \DateTimeImmutable($userId['d']);
} else {
$uid = $userId;
$date = null;
}
if (null === $user) {
return '';
}
if (null === $uid) {
continue;
}
return $this->userRender->renderString($user, []);
},
array_unique(
array_filter($decoded, static fn (?int $userId) => null !== $userId),
SORT_NUMERIC
)
)
);
$user = $this->userRepository->find($uid);
if (null === $user) {
continue;
}
$asStrings[$uid] = $this->userRender->renderString($user, ['absence' => false, 'at' => $date]);
}
return implode('|', $asStrings);
};
}
}

View File

@@ -0,0 +1,29 @@
<?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\User;
use Chill\MainBundle\Entity\User\UserJobHistory;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\OptimisticLockException;
use Doctrine\ORM\ORMException;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<UserJobHistory>
*/
class UserJobHistoryRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, UserJobHistory::class);
}
}

View File

@@ -0,0 +1,29 @@
<?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\User;
use Chill\MainBundle\Entity\User\UserScopeHistory;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\OptimisticLockException;
use Doctrine\ORM\ORMException;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<UserScopeHistory>
*/
final class UserScopeHistoryRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, UserScopeHistory::class);
}
}

View File

@@ -13,7 +13,8 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\GroupCenter;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\AbstractQuery;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NoResultException;
@@ -27,7 +28,11 @@ final readonly class UserRepository implements UserRepositoryInterface
{
private EntityRepository $repository;
public function __construct(private EntityManagerInterface $entityManager)
private const FIELDS = ['id', 'email', 'enabled', 'civility_id', 'civility_abbreviation', 'civility_name', 'label', 'mainCenter_id',
'mainCenter_name', 'mainScope_id', 'mainScope_name', 'userJob_id', 'userJob_name', 'currentLocation_id', 'currentLocation_name',
'mainLocation_id', 'mainLocation_name'];
public function __construct(private EntityManagerInterface $entityManager, private Connection $connection)
{
$this->repository = $entityManager->getRepository(User::class);
}
@@ -75,48 +80,55 @@ final readonly class UserRepository implements UserRepositoryInterface
}
/**
* @param string $lang
* @throws Exception
*/
public function findAllAsArray(string $lang): iterable
{
$dql = sprintf(<<<'DQL'
$sql = sprintf(<<<'SQL'
SELECT
u.id AS id,
u.id,
u.username AS username,
u.email,
u.email AS email,
u.enabled,
IDENTITY(u.civility) AS civility_id,
JSON_EXTRACT(civility.abbreviation, :lang) AS civility_abbreviation,
JSON_EXTRACT(civility.name, :lang) AS civility_name,
u.civility_id,
civility.abbreviation->>:lang AS civility_abbreviation,
civility.name->>:lang AS civility_name,
u.label,
mainCenter.id AS mainCenter_id,
mainCenter.name AS mainCenter_name,
IDENTITY(u.mainScope) AS mainScope_id,
JSON_EXTRACT(mainScope.name, :lang) AS mainScope_name,
IDENTITY(u.userJob) AS userJob_id,
JSON_EXTRACT(userJob.label, :lang) AS userJob_name,
mainScope.id AS mainScope_id,
mainScope.name->>:lang AS mainScope_name,
userJob.id AS userJob_id,
userJob.label->>:lang AS userJob_name,
currentLocation.id AS currentLocation_id,
currentLocation.name AS currentLocation_name,
mainLocation.id AS mainLocation_id,
mainLocation.name AS mainLocation_name,
u.absenceStart
FROM Chill\MainBundle\Entity\User u
LEFT JOIN u.civility civility
LEFT JOIN u.currentLocation currentLocation
LEFT JOIN u.mainLocation mainLocation
LEFT JOIN u.mainCenter mainCenter
LEFT JOIN u.mainScope mainScope
LEFT JOIN u.userJob userJob
ORDER BY u.label
DQL);
FROM users u
LEFT JOIN chill_main_civility civility ON u.civility_id = civility.id
LEFT JOIN centers mainCenter ON u.maincenter_id = mainCenter.id
LEFT JOIN chill_main_user_job_history userJobHistory ON u.id = userJobHistory.user_id
LEFT JOIN chill_main_user_job userJob ON userJobHistory.job_id = userJob.id AND tstzrange(userJobHistory.startdate, userJobHistory.enddate) @> NOW()
LEFT JOIN chill_main_user_scope_history userScopeHistory ON u.id = userScopeHistory.user_id AND tstzrange(userScopeHistory.startdate, userScopeHistory.enddate) @> NOW()
LEFT JOIN scopes mainScope ON userScopeHistory.scope_id = mainScope.id
LEFT JOIN chill_main_location currentLocation ON u.currentlocation_id = currentLocation.id
LEFT JOIN chill_main_location mainLocation ON u.mainlocation_id = mainLocation.id
ORDER BY u.label, u.id
SQL);
$query = $this->entityManager->createQuery($dql)
->setHydrationMode(AbstractQuery::HYDRATE_ARRAY)
->setParameter('lang', $lang)
;
$query = $this->connection->prepare($sql);
foreach ($query->toIterable() as $u) {
yield $u;
foreach ($query->executeQuery(['lang' => $lang])->iterateAssociative() as $u) {
$converted = [];
foreach (self::FIELDS as $f) {
$converted[$f] = $u[strtolower($f)];
}
$converted['absenceStart'] = null !== $u['absencestart'] ? new \DateTimeImmutable($u['absencestart']) : null;
/** @phpstan-ignore-next-line phpstan does not take into account that all required keys will be present */
yield $converted;
}
}

View File

@@ -1,10 +1,10 @@
<span class="chill-entity entity-user">
{{- user.label }}
{%- if opts['user_job'] and user.userJob is not null %}
<span class="user-job">({{ user.userJob.label|localize_translatable_string }})</span>
{%- if opts['user_job'] and user.userJob(opts['at']) is not null %}
<span class="user-job">({{ user.userJob(opts['at']).label|localize_translatable_string }})</span>
{%- endif -%}
{%- if opts['main_scope'] and user.mainScope is not null %}
<span class="main-scope">({{ user.mainScope.name|localize_translatable_string }})</span>
{%- if opts['main_scope'] and user.mainScope(opts['at']) is not null %}
<span class="main-scope">({{ user.mainScope(opts['at']).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>

View File

@@ -4,20 +4,20 @@
{% block admin_content -%}
{% embed '@ChillMain/CRUD/_index.html.twig' %}
{% block index_header %}
<h1>{{ 'List circles'|trans }}</h1>
{% endblock %}
{% block filter_order %}{% endblock %}
{% block table_entities_thead_tr %}
<th>id</th>
<th>{{ 'Name'|trans }}</th>
<th>{{ 'Active'|trans }}</th>
<th>{{ 'Actions'|trans }}</th>
{% endblock %}
{% block table_entities_tbody %}
{% for entity in entities %}
<tr>
@@ -40,9 +40,9 @@
</tr>
{% endfor %}
{% endblock %}
{% block pagination %}{% endblock %}
{% block list_actions %}
<ul class="record_actions sticky-form-buttons">
<li class='cancel'>

View File

@@ -1,7 +1,8 @@
{% extends '@ChillMain/Admin/layoutWithVerticalMenu.html.twig' %}
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block admin_content -%}
{% embed '@ChillMain/CRUD/_edit_content.html.twig' %}
{% block crud_content_after_form %}
{% if access_permissions_group_list %}
<h2 class="mt-5">{{ 'Permissions granted'|trans }}</h2>
@@ -54,5 +55,14 @@
{% endblock %}
{% block content_form_actions_save_and_show %}{% endblock %}
{% block content_form_actions_view %}
<li>
<a href="{{ path('admin_user_job_scope_history', {id: entity.id}) }}" class="btn btn-show">
{{ 'admin.users.job_scope_histories.Show history'|trans }}
</a>
</li>
{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -0,0 +1,81 @@
{% extends '@ChillMain/Admin/layoutWithVerticalMenu.html.twig' %}
{% block title %}{{ 'admin.users.job_scope_histories.index.histories'|trans }}{% endblock title %}
{% block admin_content %}
<h2>{{ user.usernameCanonical }}</h2>
<h1>{{ 'admin.users.job_scope_histories.index.histories'|trans }}</h1>
<h3 class="mt-5">{{ 'admin.users.job_scope_histories.index.job_history.title'|trans }}</h3>
<table class="records_list table table-bordered border-dark">
<thead>
<tr>
<th class="w-25">{{ 'admin.users.job_scope_histories.index.job_history.start'|trans }}</th>
<th class="w-25">{{ 'admin.users.job_scope_histories.index.job_history.end'|trans }}</th>
<th>{{ 'admin.users.job_scope_histories.index.job_history.job'|trans }}</th>
</tr>
</thead>
<tbody>
{% for entity in jobHistories %}
<tr>
<td>{{ entity.startDate|format_datetime('medium') }}</td>
<td>
{% if entity.endDate is not null %}
{{ entity.endDate|format_datetime('medium') }}
{% else %}
<i class="opacity-50">{{ "admin.users.job_scope_histories.index.job_history.today"|trans }}</i>
{% endif %}
</td>
<td>
{% if entity.job %}
{{ entity.job.label|localize_translatable_string }}
{% else %}
<i class="opacity-50">{{ 'admin.users.job_scope_histories.index.job_history.undefined'|trans }}</i>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h3 class="mt-5">{{ 'admin.users.job_scope_histories.index.scope_history.title'|trans }}</h3>
<table class="records_list table table-bordered border-dark">
<thead>
<tr>
<th class="w-25">{{ 'admin.users.job_scope_histories.index.scope_history.start'|trans }}</th>
<th class="w-25">{{ 'admin.users.job_scope_histories.index.scope_history.end'|trans }}</th>
<th>{{ 'admin.users.job_scope_histories.index.scope_history.scope'|trans }}</th>
</tr>
</thead>
<tbody>
{% for entity in scopeHistories %}
<tr>
<td>{{ entity.startDate|format_datetime('medium') }}</td>
<td>
{% if entity.endDate is not null %}
{{ entity.endDate|format_datetime('medium') }}
{% else %}
<i class="opacity-50">{{ "admin.users.job_scope_histories.index.scope_history.today"|trans }}</i>
{% endif %}
</td>
<td>
{% if entity.scope %}
{{ entity.scope.name|localize_translatable_string }}
{% else %}
<i class="opacity-50">{{ 'admin.users.job_scope_histories.index.scope_history.undefined'|trans }}</i>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<ul class="record_actions sticky-form-buttons">
<li class='cancel'>
<a href="{{ path('chill_crud_admin_user_edit', {id: user.id}) }}" class="btn btn-cancel">
{{'admin.users.job_scope_histories.index.Back to user job'|trans }}
</a>
</li>
</ul>
{% endblock %}

View File

@@ -56,7 +56,9 @@
</li>
<li>
<span class="dt">métier:</span>
{% if entity.userJob %}{{ entity.userJob.label|localize_translatable_string }}{% endif %}
{% if entity.userJob %}
{{ entity.userJob.label|localize_translatable_string }}
{% endif %}
</li>
<li>
<span class="dt">cercle/centre:</span>

View File

@@ -35,7 +35,7 @@
{% block actions_before %}
<li class='cancel'>
<a href="{{ path('chill_main_admin_central') }}" class="btn btn-cancel">{{'Back to the admin'|trans}}</a>
<a href="{{ path('chill_main_admin_central') }}" class="btn btn-cancel">{{'Back to the admin'|trans }}</a>
</li>
{% endblock %}

View File

@@ -34,7 +34,7 @@ final readonly class ScopeResolverDispatcher
/**
* @param array|null $options
* @return iterable<Scope>|Scope|null
* @return Scope|iterable<Scope>|Scope|null
*/
public function resolveScope(mixed $entity, ?array $options = []): null|\Chill\MainBundle\Entity\Scope|iterable
{

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Service\EntityInfo;
use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface;
class ViewEntityInfoManager
{
@@ -21,6 +22,7 @@ class ViewEntityInfoManager
*/
private readonly iterable $vienEntityInfoProviders,
private readonly Connection $connection,
private readonly LoggerInterface $logger,
) {}
public function synchronizeOnDB(): void
@@ -28,6 +30,8 @@ class ViewEntityInfoManager
$this->connection->transactional(function (Connection $conn): void {
foreach ($this->vienEntityInfoProviders as $viewProvider) {
foreach ($this->createOrReplaceViewSQL($viewProvider, $viewProvider->getViewName()) as $sql) {
$this->logger->debug("Will execute create view sql", ['sql' => $sql]);
$this->logger->debug($sql);
$conn->executeQuery($sql);
}
}
@@ -41,7 +45,7 @@ class ViewEntityInfoManager
{
return [
"DROP VIEW IF EXISTS {$viewName}",
sprintf("CREATE VIEW {$viewName} AS %s", $viewProvider->getViewQuery())
sprintf("CREATE OR REPLACE VIEW {$viewName} AS %s", $viewProvider->getViewQuery())
];
}
}

View File

@@ -28,10 +28,14 @@ class UserRender implements ChillEntityRenderInterface
'main_scope' => true,
'user_job' => true,
'absence' => true,
'at' => null,
];
public function __construct(private readonly TranslatableStringHelper $translatableStringHelper, private readonly \Twig\Environment $engine, private readonly TranslatorInterface $translator) {}
/**
* @param mixed $entity
*/
public function renderBox($entity, array $options): string
{
$opts = array_merge(self::DEFAULT_OPTIONS, $options);
@@ -42,20 +46,23 @@ class UserRender implements ChillEntityRenderInterface
]);
}
/**
* @param mixed $entity
*/
public function renderString($entity, array $options): string
{
$opts = array_merge(self::DEFAULT_OPTIONS, $options);
$str = $entity->getLabel();
if (null !== $entity->getUserJob() && $opts['user_job']) {
if (null !== $entity->getUserJob($opts['at']) && $opts['user_job']) {
$str .= ' (' . $this->translatableStringHelper
->localize($entity->getUserJob()->getLabel()) . ')';
->localize($entity->getUserJob($opts['at'])->getLabel()) . ')';
}
if (null !== $entity->getMainScope() && $opts['main_scope']) {
if (null !== $entity->getMainScope($opts['at']) && $opts['main_scope']) {
$str .= ' (' . $this->translatableStringHelper
->localize($entity->getMainScope()->getName()) . ')';
->localize($entity->getMainScope($opts['at'])->getName()) . ')';
}
if ($entity->isAbsent() && $opts['absence']) {

View File

@@ -0,0 +1,61 @@
<?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 Doctrine\DBAL\Types\Types;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
* @coversNothing
*/
class JsonBuildObjectTest extends KernelTestCase
{
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
$this->entityManager = self::$container->get(EntityManagerInterface::class);
}
/**
* @dataProvider provideQueries
*/
public function testQuery(string $sql, array $params, array $paramType): void
{
$query = $this->entityManager->createQuery($sql);
foreach ($params as $k => $v) {
$query->setParameter($k, $v, $paramType[$k]);
}
$query->setMaxResults(1);
$result = $query->getResult(AbstractQuery::HYDRATE_ARRAY);
self::assertIsArray($result);
}
public function provideQueries(): iterable
{
yield ["SELECT JSON_BUILD_OBJECT(1, 2, 3, 4) FROM " . Address::class . " a", [], []];
yield ["SELECT JSON_BUILD_OBJECT('st', a.street, 'sn', a.streetNumber) FROM " . Address::class . ' a', [], []];
// next query make the test fails. But we do not need it for now.
//yield ["SELECT JSON_BUILD_OBJECT(a.street, :param), LOWER(:param) FROM " . Address::class . " a", ['param' => 1], ['param' => Types::INTEGER]];
}
}

View File

@@ -0,0 +1,71 @@
<?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 Entity;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserJob;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
/**
* @internal
* @coversNothing
*/
class UserTest extends TestCase
{
use ProphecyTrait;
public function testMainScopeHistory()
{
$user = new User();
$scopeA = new Scope();
$scopeB = new Scope();
$user->setMainScope($scopeA);
$user->setMainScope($scopeB);
// 1. check getMainScope get now scopeB, not scopeA
self::assertSame($scopeB, $user->getMainScope());
// 2. get scopeA history, check endDate is not null
self::assertNotNull(
$user
->getMainScopeHistories()
->filter(fn (User\UserScopeHistory $userScopeHistory) => $userScopeHistory->getScope() === $scopeA)
->first()->getEndDate()
);
}
public function testUserJobHistory()
{
$user = new User();
$jobA = new UserJob();
$jobB = new UserJob();
$user->setUserJob($jobA);
$user->setUserJob($jobB);
// 1. check getUserJob get now jobB, not jobA
self::assertSame($jobB, $user->getUserJob());
// 2. get jobA history, check endDate is not null
self::assertNotNull(
$user
->getUserJobHistories()
->filter(fn (User\UserJobHistory $userJobHistory) => $userJobHistory->getJob() === $jobA)
->first()->getEndDate()
);
}
}

View File

@@ -18,7 +18,7 @@ final class Version20230711152947 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add data to ';
return 'Add last execution data to cronjon execution table';
}
public function up(Schema $schema): void

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\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20230913114115 extends AbstractMigration
{
public function down(Schema $schema): void
{
// drop job_history
$this->addSql('ALTER TABLE chill_main_user_job_history DROP CONSTRAINT user_job_history_endate_null_or_after_startdate');
$this->addSql('ALTER TABLE chill_main_user_job_history DROP CONSTRAINT user_job_history_not_overlaps');
$this->addSql('DROP SEQUENCE chill_main_user_job_history_id_seq CASCADE');
$this->addSql('ALTER TABLE chill_main_user_job_history DROP CONSTRAINT FK_4E3BF4DDBE04EA9');
$this->addSql('ALTER TABLE chill_main_user_job_history DROP CONSTRAINT FK_4E3BF4DDA76ED395');
$this->addSql('DROP TABLE chill_main_user_job_history');
$this->addSql('ALTER TABLE users ADD userjob_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE users ADD CONSTRAINT fk_1483a5e964b65c5b FOREIGN KEY (userjob_id) REFERENCES chill_main_user_job (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX idx_1483a5e964b65c5b ON users (userjob_id)');
// drop scope_history
$this->addSql('ALTER TABLE chill_main_user_scope_history DROP CONSTRAINT user_scope_history_endate_null_or_after_startdate');
$this->addSql('ALTER TABLE chill_main_user_scope_history DROP CONSTRAINT user_scope_history_not_overlaps');
$this->addSql('DROP SEQUENCE chill_main_user_scope_history_id_seq CASCADE');
$this->addSql('ALTER TABLE chill_main_user_scope_history DROP CONSTRAINT FK_48B969D7682B5931');
$this->addSql('ALTER TABLE chill_main_user_scope_history DROP CONSTRAINT FK_48B969D7A76ED395');
$this->addSql('DROP TABLE chill_main_user_scope_history');
$this->addSql('ALTER TABLE users ADD mainscope_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE users ADD CONSTRAINT fk_1483a5e9115e73f3 FOREIGN KEY (mainscope_id) REFERENCES scopes (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX idx_1483a5e9115e73f3 ON users (mainscope_id)');
}
public function getDescription(): string
{
return 'Add new entities UserScopeHistory and UserJobHistory';
}
public function up(Schema $schema): void
{
// create scope_history
$this->addSql('CREATE SEQUENCE chill_main_user_scope_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE chill_main_user_scope_history ('
. 'id INT NOT NULL,'
. 'scope_id INT DEFAULT NULL,'
. 'user_id INT DEFAULT NULL,'
. 'endDate TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,'
. 'startDate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,'
. 'PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_48B969D7682B5931 ON chill_main_user_scope_history (scope_id)');
$this->addSql('CREATE INDEX IDX_48B969D7A76ED395 ON chill_main_user_scope_history (user_id)');
$this->addSql('COMMENT ON COLUMN chill_main_user_scope_history.endDate IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_main_user_scope_history.startDate IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE chill_main_user_scope_history ADD CONSTRAINT FK_48B969D7682B5931 '
. 'FOREIGN KEY (scope_id) REFERENCES scopes (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_main_user_scope_history ADD CONSTRAINT FK_48B969D7A76ED395 '
. 'FOREIGN KEY (user_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_main_user_scope_history '
. 'ADD CONSTRAINT user_scope_history_not_overlaps '
. 'EXCLUDE USING GIST (user_id with =, tsrange(startDate, endDate) with &&) '
. 'DEFERRABLE INITIALLY DEFERRED');
$this->addSql('ALTER TABLE chill_main_user_scope_history '
. 'ADD CONSTRAINT user_scope_history_endate_null_or_after_startdate '
. 'CHECK (startDate <= endDate OR endDate IS NULL)');
// insert user scope_history datas
$this->addSql('INSERT INTO chill_main_user_scope_history (id, startDate, endDate, user_id, scope_id) '
. 'SELECT nextval(\'chill_main_user_scope_history_id_seq\'), \'1970-01-01\'::date, NULL, users.id, mainscope_id '
. 'FROM users');
// remove mainscope
$this->addSql('ALTER TABLE users DROP CONSTRAINT fk_1483a5e9115e73f3');
$this->addSql('ALTER TABLE users DROP mainscope_id');
// create job_history
$this->addSql('CREATE SEQUENCE chill_main_user_job_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE chill_main_user_job_history ('
. 'id INT NOT NULL,'
. 'job_id INT DEFAULT NULL,'
. 'user_id INT DEFAULT NULL,'
. 'endDate TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,'
. 'startDate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,'
. 'PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_4E3BF4DDBE04EA9 ON chill_main_user_job_history (job_id)');
$this->addSql('CREATE INDEX IDX_4E3BF4DDA76ED395 ON chill_main_user_job_history (user_id)');
$this->addSql('COMMENT ON COLUMN chill_main_user_job_history.endDate IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_main_user_job_history.startDate IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE chill_main_user_job_history ADD CONSTRAINT FK_4E3BF4DDBE04EA9 '
. 'FOREIGN KEY (job_id) REFERENCES chill_main_user_job (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_main_user_job_history ADD CONSTRAINT FK_4E3BF4DDA76ED395 '
. 'FOREIGN KEY (user_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_main_user_job_history '
. 'ADD CONSTRAINT user_job_history_not_overlaps '
. 'EXCLUDE USING GIST (user_id with =, tsrange(startDate, endDate) with &&) '
. 'DEFERRABLE INITIALLY DEFERRED');
$this->addSql('ALTER TABLE chill_main_user_job_history '
. 'ADD CONSTRAINT user_job_history_endate_null_or_after_startdate '
. 'CHECK (startDate <= endDate OR endDate IS NULL)');
// insert user job_history datas
$this->addSql('INSERT INTO chill_main_user_job_history (id, startDate, endDate, user_id, job_id) '
. 'SELECT nextval(\'chill_main_user_job_history_id_seq\'), \'1970-01-01\'::date, NULL, users.id, userjob_id '
. 'FROM users');
// remove userjob
$this->addSql('ALTER TABLE users DROP CONSTRAINT fk_1483a5e964b65c5b');
$this->addSql('ALTER TABLE users DROP userjob_id');
}
}

View File

@@ -651,3 +651,24 @@ admin:
center_name: Centre
permissionsGroup_id: Identifiant du groupe de permissions
permissionsGroup_name: Groupe de permissions
job_scope_histories:
Show history: Voir l'historique
index:
histories: Historique des services et des métiers
Back to user job: Revenir à l'utilisateur
job_history:
title: Historique des métiers
start: Du
end: Jusque
today: en cours
undefined: non défini
user: Utilisateur
job: Métier
scope_history:
title: Historique des services
start: Du
end: Jusque
today: en cours
undefined: non défini
user: Utilisateur
scope: Service