Compare commits

...

23 Commits

Author SHA1 Message Date
2a280b814f Refactor view templates: relocate 'merge' action block and standardize 'duplicate link' block handling 2025-09-09 17:36:46 +02:00
230c758255 Update bundles to v4.3.0 2025-09-08 16:05:09 +02:00
eafda987ae Merge branch '412-absence-enddate' into 'master'
Resolve "Absence user: add end date"

Closes #412

See merge request Chill-Projet/chill-bundles!865
2025-09-08 13:47:14 +00:00
7db8a371fc Resolve "Absence user: add end date" 2025-09-08 13:47:14 +00:00
0d0649dd31 Change route URL to avoid clash with person duplicate controller method 2025-09-08 14:51:54 +02:00
ac12b8cdcf Merge branch 'add-permission-list-command' into 'master'
Add `RoleDumper` and `DumpListPermissionsCommand` to generate a markdown list of permissions

See merge request Chill-Projet/chill-bundles!874
2025-09-05 16:55:45 +00:00
9c1611d052 Add RoleDumper and DumpListPermissionsCommand to generate a markdown list of permissions 2025-09-05 16:55:45 +00:00
90e3043c3d Junie guidelines: fix grammar and typos in development guidelines 2025-09-04 17:26:55 +02:00
af13bf9088 Update chill bundles to v4.2.1 2025-09-03 21:12:21 +02:00
4aa65d69c7 Merge branch 'master' of gitlab.com:Chill-Projet/chill-bundles 2025-09-03 21:11:06 +02:00
9e33aec594 Handle different export types in ExportConfigNormalizer and allow null/array checks for dataFormatter in ExportController 2025-09-03 21:10:58 +02:00
f88bc7e9f0 Merge branch 'improve-local-storage' into 'master'
Improve error handling when saving objects to local disk

See merge request Chill-Projet/chill-bundles!872
2025-09-02 19:59:26 +00:00
8e78c41549 Improve error handling when saving objects to local disk by using dumpFile with detailed exception logging. 2025-09-02 21:53:40 +02:00
6e36771349 fix changelog 2025-09-02 17:52:20 +02:00
7a82cae155 Release v4.2.0 2025-09-02 17:13:28 +02:00
dfab223391 Merge branch 'master' of gitlab.com:Chill-Projet/chill-bundles 2025-09-02 16:14:13 +02:00
539752485c Allow null values for alias and dataFormatter in buildExportDataForNormalization method 2025-09-02 16:13:48 +02:00
d204df0316 Merge branch '422-password-recover-layout' into 'master'
Resolve "Fix layout of password recover pages"

Closes #422

See merge request Chill-Projet/chill-bundles!869
2025-09-02 08:29:27 +00:00
juminet
82c02f442b Resolve "Fix layout of password recover pages" 2025-09-02 08:29:26 +00:00
f32a9dc7bc Merge branch '64-identifiant-personne' into 'master'
Add external identifiers for person, editable in edit form, with minimal features associated

See merge request Chill-Projet/chill-bundles!871
2025-09-01 08:05:11 +00:00
ea06a96f91 Add external identifiers for person, editable in edit form, with minimal features associated 2025-09-01 08:05:11 +00:00
76433e2512 Fix incorrect parameter name in event details link 2025-08-28 13:49:45 +02:00
1fa464b87a Fix typo in 'uncheckAll' script for centers selection 2025-08-28 13:32:43 +02:00
86 changed files with 1854 additions and 262 deletions

View File

@@ -0,0 +1,6 @@
kind: Fixed
body: Fix display of 'duplicate' and 'merge' buttons in CRUD templates
time: 2025-09-09T17:36:39.960419639+02:00
custom:
Issue: ""
SchemaChange: No schema change

10
.changes/v4.2.0.md Normal file
View File

@@ -0,0 +1,10 @@
## v4.2.0 - 2025-09-02
### Feature
* ([#64](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/64)) Add external identifier for a Person
**Schema Change**: Add columns or tables
* ([#330](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/330) Allow users to choose for which notifications they want to receive an email
### Fixed
* ([#422](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/422)) Fixed html layout of pages for recovering password
* Fix typo in 'uncheckAll' script for centers selection
* Fix incorrect parameter name in event details link

6
.changes/v4.2.1.md Normal file
View File

@@ -0,0 +1,6 @@
## v4.2.1 - 2025-09-03
### Fixed
* Fix exports to work with DirectExportInterface
### DX
* Improve error message when a stored object cannot be written on local disk

10
.changes/v4.3.0.md Normal file
View File

@@ -0,0 +1,10 @@
## v4.3.0 - 2025-09-08
### Feature
* ([#409](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/409)) Add 45 and 60 min calendar ranges
* Add a command to generate a list of permissions
* ([#412](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/412)) Add an absence end date
**Schema Change**: Add columns or tables
### Fixed
* fix date formatting in calendar range display
* Change route URL to avoid clash with person duplicate controller method

3
.gitignore vendored
View File

@@ -18,6 +18,9 @@ migrations/*
templates/*
translations/*
# we allow developers to add customization on their installation, without commiting it
config/packages/dev/*
###> symfony/framework-bundle ###
/.env.local
/.env.local.php

View File

@@ -27,11 +27,11 @@ Chill is a comprehensive web application built as a set of Symfony bundles. It i
## Project Structure
Note: This is a project which exists from a long time ago, and we found multiple structure inside each bundle. When having the choice, the developers should choose the new structure.
Note: This is a project that's existed for a long time, and throughout the years we've used multiple structures inside each bundle. When having the choice, the developers should choose the new structure.
The project follows a standard Symfony bundle structure:
- `/src/Bundle/`: Contains all the Chill bundles. The code is either at the root of the bundle directory, or within a `src/` directory (preferred). See psr4 mapping at the root's `composer.json`.
- each bundle come with his own tests, either in the `Tests` directory (when the code is directly within the bundle directory (for instance `src/Bundle/ChillMainBundle/Tests`, `src/Bundle/ChillPersonBundle/Tests`)), or inside the `tests` directory, alongside to the `src/` sub-directory (example: `src/Bundle/ChillWopiBundle/tests`) (this is the preferred way).
- each bundle comes with its own tests, either in the `Tests` directory (when the code is directly within the bundle directory (for instance `src/Bundle/ChillMainBundle/Tests`, `src/Bundle/ChillPersonBundle/Tests`)), or inside the `tests` directory, alongside the `src/` sub-directory (example: `src/Bundle/ChillWopiBundle/tests`) (this is the preferred way).
- `/docs/`: Contains project documentation
Each bundle typically has the following structure:
@@ -46,13 +46,13 @@ Each bundle typically has the following structure:
### A special word about TicketBundle
The ticket bundle is developed using a kind of "Command" pattern. The controller fill a "Command", and a "CommandHandler" handle this command. They are savec in the `src/Bundle/ChillTicketBundle/src/Action` directory.
The ticket bundle is developed using a kind of "Command" pattern. The controller fills a "Command," and a "CommandHandler" handles this command. They are saved in the `src/Bundle/ChillTicketBundle/src/Action` directory.
## Development Guidelines
### Building and Configuration Instructions
All the command should be run through the `symfony` command, which will configure the required variables.
All the commands should be run through the `symfony` command, which will configure the required variables.
For assets, we must ensure that we use node at version `^20.0.0`. This is done using `nvm use 20`.
@@ -87,7 +87,7 @@ For assets, we must ensure that we use node at version `^20.0.0`. This is done u
docker compose up -d
```
5. **Set Up the Database**:
6. **Set Up the Database**:
```bash
# Create the database
symfony console doctrine:database:create
@@ -99,20 +99,20 @@ For assets, we must ensure that we use node at version `^20.0.0`. This is done u
symfony console doctrine:fixtures:load
```
6. **Build Assets**:
7. **Build Assets**:
```bash
nvm use 20
yarn run encore dev
```
7. **Start the Development Server**:
8. **Start the Development Server**:
```bash
symfony server:start -d
```
#### Docker Setup
The project includes Docker configuration for easier development:
The project includes a Docker configuration for easier development:
1. **Start Docker Services**:
```bash
@@ -153,9 +153,9 @@ Key configuration files:
Each time a doctrine entity is created, we generate migration to adapt the database.
The migration are created using the command `symfony console doctrine:migrations:diff --no-interaction --namespace <namespace>`, where the namespace is the relevant namespace for migration. As this is a bash script, do not forget to quote the `\` (`\` must become `\\` in your command).
The migration is created using the command `symfony console doctrine:migrations:diff --no-interaction --namespace <namespace>`, where the namespace is the relevant namespace for migration. As this is a bash script, remember to quote the `\` (`\` must become `\\` in your command).
Each bundle has his own namespace for migration (always ask me to confirm that command, with a list of updated / created entities so that I can confirm you that it is ok):
Each bundle has his own namespace for migration (always ask me to confirm that command with a list of updated / created entities so that I can confirm to you that it is ok):
- `Chill\Bundle\ActivityBundle` writes migrations to `Chill\Migrations\Activity`;
- `Chill\Bundle\BudgetBundle` writes migrations to `Chill\Migrations\Budget`;
@@ -183,7 +183,7 @@ Once created the, comment's classes should be removed and a description of the c
When we need to use a DateTime or DateTimeImmutable that need to express "now", we prefer the usage of
`Symfony\Component\Clock\ClockInterface`, where possible. This is usually not possible in doctrine entities,
where injection does not work when restoring an entity from database, but usually possible in services.
where injection does not work when restoring an entity from a database, but usually possible in services.
In test, we use `\Symfony\Component\Clock\MockClock` which is an implementation of `Symfony\Component\Clock\ClockInterface`
where we have full and easy control of the date.
@@ -198,9 +198,9 @@ The project uses PHPUnit for testing. Each bundle has its own test suite, and th
For creating mock, we prefer using prophecy (library phpspec/prophecy).
##### Useful helpers and tips that avoid create a mock
##### Useful helpers and tips that avoid creating a mock
Some notable implementations that are tests helper, and avoid to create a mock:
Some notable implementations that are test helpers and avoid creating a mock:
- `\Psr\Log\NullLogger`, an implementation of `\Psr\Log\LoggerInterface`;
- `\Symfony\Component\Clock\MockClock`, an implementation of `Symfony\Component\Clock\ClockInterface` (already mentioned above);
@@ -297,7 +297,7 @@ class TicketTest extends TestCase
#### Test Database
For tests that require a database, the project uses postgresql database filled by fixtures (usage of doctrine-fixtures). You can configure a different database for testing in the `.env.test` file.
For tests that require a database, the project uses a postgresql database filled with fixtures (usage of doctrine-fixtures). You can configure a different database for testing in the `.env.test` file.
### Code Quality Tools

View File

@@ -6,6 +6,35 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## v4.3.0 - 2025-09-08
### Feature
* ([#409](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/409)) Add 45 and 60 min calendar ranges
* Add a command to generate a list of permissions
* ([#412](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/412)) Add an absence end date
**Schema Change**: Add columns or tables
### Fixed
* fix date formatting in calendar range display
* Change route URL to avoid clash with person duplicate controller method
## v4.2.1 - 2025-09-03
### Fixed
* Fix exports to work with DirectExportInterface
### DX
* Improve error message when a stored object cannot be written on local disk
## v4.2.0 - 2025-09-02
### Feature
* ([#64](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/64)) Add external identifier for a Person
**Schema Change**: Add columns or tables
* ([#330](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/330) Allow users to choose for which notifications they want to receive an email
### Fixed
* ([#422](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/422)) Fixed html layout of pages for recovering password
* Fix typo in 'uncheckAll' script for centers selection
* Fix incorrect parameter name in event details link
## v4.1.0 - 2025-08-26
### Feature
* ([#400](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/400)) Add filter to social actions list to filter out actions where current user intervenes

View File

@@ -55,5 +55,6 @@
</dl>
{% endblock %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -70,6 +70,8 @@
<option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option>
<option value="00:45:00">45 minutes</option>
<option value="00:60:00">60 minutes</option>
</select>
<label class="input-group-text" for="slotMinTime">De</label>
<select

View File

@@ -32,6 +32,8 @@
<option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option>
<option value="00:45:00">45 minutes</option>
<option value="00:60:00">60 minutes</option>
</select>
<label class="input-group-text" for="slotMinTime">De</label>
<select
@@ -102,7 +104,8 @@
event.title
}}</b>
<b v-else-if="event.extendedProps.is === 'range'"
>{{ formatDate(event.startStr) }} -
>{{ formatDate(event.startStr, "time") }} -
{{ formatDate(event.endStr, "time") }}:
{{ event.extendedProps.locationName }}</b
>
<b v-else-if="event.extendedProps.is === 'local'">{{
@@ -294,9 +297,26 @@ const nextWeeks = computed((): Weeks[] =>
}),
);
const formatDate = (datetime: string) => {
console.log(typeof datetime);
return ISOToDate(datetime);
const formatDate = (datetime: string, format: null | "time" = null) => {
const date = ISOToDate(datetime);
if (!date) return "";
if (format === "time") {
return date.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
});
}
// French date formatting
return date.toLocaleDateString("fr-FR", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const baseOptions = ref<CalendarOptions>({

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\CustomFieldsBundle\EntityRepository;
use Chill\CustomFieldsBundle\Entity\CustomFieldsDefaultGroup;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class CustomFieldsDefaultGroupRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, CustomFieldsDefaultGroup::class);
}
public function findOneByEntity(string $className): ?CustomFieldsDefaultGroup
{
return $this->findOneBy(['entity' => $className]);
}
}

View File

@@ -127,3 +127,7 @@ services:
factory: ["@doctrine", getRepository]
arguments:
- "Chill\\CustomFieldsBundle\\Entity\\CustomFieldLongChoice\\Option"
Chill\CustomFieldsBundle\EntityRepository\CustomFieldsDefaultGroupRepository:
autowire: true
autoconfigure: true

View File

@@ -18,6 +18,7 @@ use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\DocStoreBundle\Service\Cryptography\KeyGenerator;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Filesystem\Path;
@@ -147,16 +148,11 @@ class StoredObjectManager implements StoredObjectManagerInterface
public function writeContent(string $filename, string $encryptedContent): void
{
$fullPath = $this->buildPath($filename);
$dir = Path::getDirectory($fullPath);
if (!$this->filesystem->exists($dir)) {
$this->filesystem->mkdir($dir);
}
$result = file_put_contents($fullPath, $encryptedContent);
if (false === $result) {
throw StoredObjectManagerException::unableToStoreDocumentOnDisk();
try {
$this->filesystem->dumpFile($fullPath, $encryptedContent);
} catch (IOExceptionInterface $exception) {
throw StoredObjectManagerException::unableToStoreDocumentOnDisk($exception);
}
}

View File

@@ -53,7 +53,7 @@
{% set returnLabel = 'Back to %person% events'|trans({ '%person%' : currentPerson } ) %}
{% if is_granted('CHILL_EVENT_SEE_DETAILS', participation.event) %}
<a href="{{ path('chill_event__event_show', { 'event_id' : participation.event.id, 'return_path' : currentPath, 'return_label' : returnLabel } ) }}"
<a href="{{ path('chill_event__event_show', { 'id' : participation.event.id, 'return_path' : currentPath, 'return_label' : returnLabel } ) }}"
class="btn btn-primary btn-sm" title="{{ 'See details of the event'|trans }}">
<i class="fa fa-fw fa-eye"></i>
</a>

View File

@@ -118,7 +118,7 @@
{{ entity.notes|chill_print_or_message("Aucune note", 'blockquote') }}
{% endblock crud_content_view_details %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% block content_view_actions_back %}
<li class="cancel">
<a class="btn btn-cancel" href="{{ chill_return_path_or('chill_job_report_index', { 'person': entity.person.id }) }}">

View File

@@ -46,6 +46,7 @@
</dd>
</dl>
{% endblock crud_content_view_details %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% block content_view_actions_back %}
<li class="cancel">

View File

@@ -206,6 +206,8 @@
</a>
</li>
{% endblock %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% block content_view_actions_after %}
<li>
<a class="btn btn-misc" href="{{ chill_return_path_or('chill_crud_immersion_bilan', { 'id': entity.id, 'person_id': entity.person.id }) }}">

View File

@@ -94,6 +94,7 @@
{% endblock crud_content_view_details %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% block content_view_actions_back %}
<li class="cancel">

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Command;
use Chill\MainBundle\Security\RoleDumper;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'chill:main:dump-list-permissions', description: 'Print a markdown reference of permissions (roles) grouped by title with dependencies).')]
final class DumpListPermissionsCommand extends Command
{
public function __construct(private readonly RoleDumper $roleDumper)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$markdown = $this->roleDumper->dumpAsMarkdown();
$output->writeln($markdown);
return Command::SUCCESS;
}
}

View File

@@ -48,6 +48,7 @@ class AbsenceController extends AbstractController
$user = $this->security->getUser();
$user->setAbsenceStart(null);
$user->setAbsenceEnd(null);
$em = $this->managerRegistry->getManager();
$em->flush();

View File

@@ -345,7 +345,7 @@ class ExportController extends AbstractController
* @param array $dataExport Raw data from export step
* @param array $dataFormatter Raw data from formatter step
*/
private function buildExportDataForNormalization(string $alias, ?array $dataCenters, array $dataExport, array $dataFormatter, ?SavedExport $savedExport): array
private function buildExportDataForNormalization(string $alias, ?array $dataCenters, array $dataExport, ?array $dataFormatter, ?SavedExport $savedExport): array
{
if ($this->filterStatsByCenters) {
$formCenters = $this->createCreateFormExport($alias, 'generate_centers', [], null);
@@ -365,7 +365,7 @@ class ExportController extends AbstractController
$formExport->submit($dataExport);
$dataExport = $formExport->getData();
if (\count($dataFormatter) > 0) {
if (is_array($dataFormatter) && \count($dataFormatter) > 0) {
$formFormatter = $this->createCreateFormExport(
$alias,
'generate_formatter',
@@ -381,7 +381,7 @@ class ExportController extends AbstractController
'export' => $dataExport['export']['export'] ?? [],
'filters' => $dataExport['export']['filters'] ?? [],
'aggregators' => $dataExport['export']['aggregators'] ?? [],
'pick_formatter' => $dataExport['export']['pick_formatter']['alias'],
'pick_formatter' => ($dataExport['export']['pick_formatter'] ?? [])['alias'] ?? '',
'formatter' => $dataFormatter['formatter'] ?? [],
];
}

View File

@@ -24,6 +24,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
use Symfony\Component\Validator\Constraints as Assert;
/**
* User.
@@ -45,6 +46,8 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $absenceStart = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $absenceEnd = null;
/**
* Array where SAML attributes's data are stored.
*/
@@ -157,6 +160,11 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
return $this->absenceStart;
}
public function getAbsenceEnd(): ?\DateTimeImmutable
{
return $this->absenceEnd;
}
/**
* Get attributes.
*
@@ -336,7 +344,13 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
public function isAbsent(): bool
{
return null !== $this->getAbsenceStart() && $this->getAbsenceStart() <= new \DateTimeImmutable('now');
$now = new \DateTimeImmutable('now');
$absenceStart = $this->getAbsenceStart();
$absenceEnd = $this->getAbsenceEnd();
return null !== $absenceStart
&& $absenceStart <= $now
&& (null === $absenceEnd || $now <= $absenceEnd);
}
/**
@@ -410,6 +424,11 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
$this->absenceStart = $absenceStart;
}
public function setAbsenceEnd(?\DateTimeImmutable $absenceEnd): void
{
$this->absenceEnd = $absenceEnd;
}
public function setAttributeByDomain(string $domain, string $key, $value): self
{
$this->attributes[$domain][$key] = $value;
@@ -675,4 +694,16 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
{
return 'fr';
}
#[Assert\Callback]
public function validateAbsenceDates(ExecutionContextInterface $context): void
{
if (null !== $this->getAbsenceEnd() && null === $this->getAbsenceStart()) {
$context->buildViolation(
'user.absence_end_requires_start'
)
->atPath('absenceEnd')
->addViolation();
}
}
}

View File

@@ -20,7 +20,7 @@ use Chill\MainBundle\Repository\CenterRepositoryInterface;
use Chill\MainBundle\Repository\RegroupmentRepositoryInterface;
/**
* @phpstan-type NormalizedData array{centers: array{centers: list<int>, regroupments: list<int>}, export: array{form: array<string, mixed>, version: int}, filters: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, aggregators: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, pick_formatter: string, formatter: array{form: array<string, mixed>, version: int}}
* @phpstan-type NormalizedData array{centers: array{centers: list<int>, regroupments: list<int>}, export: array{form: array<string, mixed>, version: int}, filters: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, aggregators: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, pick_formatter?: string, formatter: array{form: array<string, mixed>, version: int}}
*/
class ExportConfigNormalizer
{
@@ -72,10 +72,14 @@ class ExportConfigNormalizer
}
$serialized['aggregators'] = $aggregatorsSerialized;
$serialized['pick_formatter'] = $formData['pick_formatter'];
$formatter = $this->exportManager->getFormatter($formData['pick_formatter']);
$serialized['formatter']['form'] = $formatter->normalizeFormData($formData['formatter']);
$serialized['formatter']['version'] = $formatter->getNormalizationVersion();
if ($export instanceof ExportInterface) {
$serialized['pick_formatter'] = $formData['pick_formatter'];
$formatter = $this->exportManager->getFormatter($formData['pick_formatter']);
$serialized['formatter']['form'] = $formatter->normalizeFormData($formData['formatter']);
$serialized['formatter']['version'] = $formatter->getNormalizationVersion();
} elseif ($export instanceof DirectExportInterface) {
$serialized['formatter'] = ['form' => [], 'version' => 0];
}
return $serialized;
}
@@ -87,7 +91,12 @@ class ExportConfigNormalizer
public function denormalizeConfig(string $exportAlias, array $serializedData, bool $replaceDisabledByDefaultData = false): array
{
$export = $this->exportManager->getExport($exportAlias);
$formater = $this->exportManager->getFormatter($serializedData['pick_formatter']);
if ($export instanceof ExportInterface) {
$formatter = $this->exportManager->getFormatter($serializedData['pick_formatter']);
} else {
$formatter = null;
}
$filtersConfig = [];
foreach ($serializedData['filters'] as $alias => $filterData) {
@@ -117,8 +126,8 @@ class ExportConfigNormalizer
'export' => $export->denormalizeFormData($serializedData['export']['form'], $serializedData['export']['version']),
'filters' => $filtersConfig,
'aggregators' => $aggregatorsConfig,
'pick_formatter' => $serializedData['pick_formatter'],
'formatter' => $formater->denormalizeFormData($serializedData['formatter']['form'], $serializedData['formatter']['version']),
'pick_formatter' => $serializedData['pick_formatter'] ?? '',
'formatter' => $formatter?->denormalizeFormData($serializedData['formatter']['form'], $serializedData['formatter']['version']),
'centers' => [
'centers' => array_values(array_filter(array_map(fn (int $id) => $this->centerRepository->find($id), $serializedData['centers']['centers']), fn ($item) => null !== $item)),
'regroupments' => array_values(array_filter(array_map(fn (int $id) => $this->regroupmentRepository->find($id), $serializedData['centers']['regroupments']), fn ($item) => null !== $item)),

View File

@@ -23,9 +23,14 @@ class AbsenceType extends AbstractType
{
$builder
->add('absenceStart', ChillDateType::class, [
'required' => true,
'required' => false,
'input' => 'datetime_immutable',
'label' => 'absence.Absence start',
])
->add('absenceEnd', ChillDateType::class, [
'required' => false,
'input' => 'datetime_immutable',
'label' => 'absence.Absence end',
]);
}

View File

@@ -105,6 +105,11 @@ class UserType extends AbstractType
'required' => false,
'input' => 'datetime_immutable',
'label' => 'absence.Absence start',
])
->add('absenceEnd', ChillDateType::class, [
'required' => false,
'input' => 'datetime_immutable',
'label' => 'absence.Absence end',
]);
// @phpstan-ignore-next-line

View File

@@ -170,13 +170,14 @@ div.banner {
font-weight: lighter;
font-size: 50%;
margin-left: 0.5em;
&:before { content: '(n°'; }
&:after { content: ')'; }
&.same-size {
font-size: unset;
font-weight: unset;
}
}
span.age {
margin-left: 0.5em;
&:before { content: '('; }
&:after { content: ')'; }
}
}

View File

@@ -37,8 +37,13 @@ export const ISOToDate = (str: string | null): Date | null => {
return null;
}
const [year, month, day] = str.split("-").map((p) => parseInt(p));
// If the string already contains time info, use it directly
if (str.includes("T") || str.includes(" ")) {
return new Date(str);
}
// Otherwise, parse date only
const [year, month, day] = str.split("-").map((p) => parseInt(p));
return new Date(year, month - 1, day, 0, 0, 0, 0);
};
@@ -69,20 +74,19 @@ export const ISOToDatetime = (str: string | null): Date | null => {
*
*/
export const datetimeToISO = (date: Date): string => {
let cal, time, offset;
cal = [
const cal = [
date.getFullYear(),
(date.getMonth() + 1).toString().padStart(2, "0"),
date.getDate().toString().padStart(2, "0"),
].join("-");
time = [
const time = [
date.getHours().toString().padStart(2, "0"),
date.getMinutes().toString().padStart(2, "0"),
date.getSeconds().toString().padStart(2, "0"),
].join(":");
offset = [
const offset = [
date.getTimezoneOffset() <= 0 ? "+" : "-",
Math.abs(Math.floor(date.getTimezoneOffset() / 60))
.toString()

View File

@@ -44,8 +44,6 @@ section.chill-entity {
margin-left: 0.5em;
}
span.id-number {
&:before { content: '(n°'; }
&:after { content: ')'; }
}
}
p.moreinfo {}

View File

@@ -44,17 +44,7 @@
{% endif %}
{% endif %}
{% endblock content_view_actions_duplicate_link %}
{% block content_view_actions_merge %}
<li>
<a href="{{ chill_path_add_return_path('chill_thirdparty_find_duplicate',
{ 'thirdparty_id': entity.id }) }}"
title="{{ 'Merge'|trans }}"
class="btn btn-misc">
<i class="bi bi-chevron-contract"></i>
{{ 'Merge'|trans }}
</a>
</li>
{% endblock %}
{% block content_view_actions_merge %}{% endblock %}
{% block content_view_actions_edit_link %}
{% if chill_crud_action_exists(crud_name, 'edit') %}
{% if is_granted(chill_crud_config('role', crud_name, 'edit'), entity) %}

View File

@@ -63,8 +63,7 @@
<script>
const uncheckAll = () => {
const allCenters = document.getElementsByName('centers[center][]');
const allCenters = document.getElementsByName('centers[centers][]');
allCenters.forEach(checkbox => checkbox.checked = false)
}
</script>

View File

@@ -0,0 +1,13 @@
<header>
<nav class="navbar navbar-dark bg-primary navbar-expand-md">
<div class="container-xxl">
<div class="col-12">
<a class="navbar-brand" href="{{ path('chill_main_homepage') }}">
{{ include('@ChillMain/Layout/_header-logo.html.twig') }}
</a>
</div>
</div>
</nav>
</header>

View File

@@ -8,36 +8,36 @@
<div class="col-md-10">
<h2>{{ 'absence.My absence'|trans }}</h2>
<div>
{% if user.absenceStart is not null %}
<div class="alert alert-success flash_message">{{ 'absence.You are listed as absent, as of {date, date, short}'|trans({
date: user.absenceStart
}) }}
{% if user.absenceEnd is not null %}
{{ 'until %date%'|trans({'%date%': user.absenceEnd|format_date('short') }) }}
{% endif %}
</div>
{% else %}
<div class="alert alert-warning flash_message">{{ 'absence.No absence listed'|trans }}</div>
{% endif %}
</div>
<div>
{{ form_start(form) }}
{{ form_row(form.absenceStart) }}
{{ form_row(form.absenceEnd) }}
{% if user.absenceStart is not null %}
<div>
<p>{{ 'absence.You are listed as absent, as of'|trans }} {{ user.absenceStart|format_date('long') }}</p>
<ul class="record_actions sticky-form-buttons">
<li>
<a href="{{ path('chill_main_user_absence_unset') }}"
class="btn btn-delete">{{ 'absence.Unset absence'|trans }}</a>
</li>
</ul>
</div>
{% else %}
<div>
<p class="chill-no-data-statement">{{ 'absence.No absence listed'|trans }}</p>
</div>
<div>
{{ form_start(form) }}
{{ form_row(form.absenceStart) }}
<ul class="record_actions sticky-form-buttons">
<li>
<button class="btn btn-save" type="submit">
{{ 'Save'|trans }}
</button>
</li>
</ul>
{{ form_end(form) }}
</div>
{% endif %}
<ul class="record_actions sticky-form-buttons">
<li>
<a class="btn btn-delete" title="Modifier" href="{{ path('chill_main_user_absence_unset') }}">{{ 'absence.Unset absence'|trans }}</a>
</li>
<li>
<button class="btn btn-save" type="submit">
{{ 'Save'|trans }}
</button>
</li>
</ul>
{{ form_end(form) }}
</div>
</div>
{% endblock %}

View File

@@ -26,11 +26,12 @@
{{ 'Welcome' | trans }}<br/>
<b>
{{ app.user.username }}
{{ render(controller('Chill\\MainBundle\\Controller\\UIController::showNotificationUserCounterAction')) }}
</b>
{% if app.user %}
<b>
{{ app.user.username }}
{{ render(controller('Chill\\MainBundle\\Controller\\UIController::showNotificationUserCounterAction')) }}
</b>
{% endif %}
{% if is_granted('IS_IMPERSONATOR') %}
<i class="fa fa-wrench fa-lg" title="Impersonate mode"></i>
{% endif %}

View File

@@ -16,29 +16,16 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>
{{ 'Login to %installation_name%' | trans({ '%installation_name%' : installation.name } ) }}
</title>
<link rel="shortcut icon" href="{{ asset('build/images/favicon.ico') }}" type="image/x-icon">
{{ encore_entry_link_tags('chill') }}
</head>
<body>
<header class="navigation container-fluid">
<div class="col-4 d-md-none parent">
<div class="col-10 col-md-12 offset-2 logo-container">
<a href="{{ path('chill_main_homepage') }}">
<img class="logo" src="{{ asset('build/images/logo-chill-sans-slogan_white.png') }}">
</a>
</div>
</div>
</header>
{% extends "@ChillMain/layout.html.twig" %}
<div id="content">
{% block content %}{% endblock %}
</div>
</body>
</html>
{% set header_logo_only = 1 %}
{% block title %}{{ 'Login to %installation_name%' | trans({ '%installation_name%' : installation.name } ) }}{% endblock %}
{% block content %}
<div id="content">
{% block password_content %}{% endblock %}
</div>
{% endblock %}

View File

@@ -2,7 +2,7 @@
{% block title %}{{ "New password set"|trans }}{% endblock %}
{% block content %}
{% block password_content %}
<div class="col-10 centered">
<h1>{{ "New password set"|trans }}</h1>

View File

@@ -4,7 +4,7 @@
{% block title %}{{ title }}{% endblock %}
{% block content %}
{% block password_content %}
<div class="col-10 centered">
<h1>{{ title }}</h1>

View File

@@ -22,7 +22,7 @@
{% block title %}{{"Recover password"|trans}}{% endblock %}
{% block content %}
{% block password_content %}
<div class="col-10 centered">
<h1>{{ 'Recover password'|trans }}</h1>

View File

@@ -2,7 +2,7 @@
{% block title "Check your email"|trans %}
{% block content %}
{% block password_content %}
<div class="col-10 centered">

View File

@@ -30,7 +30,11 @@
{{ include('@ChillMain/Layout/_debug.html.twig') }}
{% endif %}
{{ include('@ChillMain/Layout/_header.html.twig') }}
{% if header_logo_only is defined and header_logo_only == 1 %}
{{ include('@ChillMain/Layout/_header_logo_only.html.twig') }}
{% else %}
{{ include('@ChillMain/Layout/_header.html.twig') }}
{% endif %}
{% block top_banner %}{#
To use if you want to add a banner below the header (ie the menu)
@@ -75,7 +79,7 @@
<div class="d-flex flex-row mb-5 alert alert-warning" role="alert">
<p class="m-2">{{'absence.You are marked as being absent'|trans }}</p>
<span class="ms-auto">
<a class="btn btn-remove" title="Modifier" href="{{ path('chill_main_user_absence_index') }}">{{ 'absence.Unset absence'|trans }}</a>
<a class="btn btn-delete" title="Modifier" href="{{ path('chill_main_user_absence_unset') }}">{{ 'absence.Unset absence'|trans }}</a>
</span>
</div>
{% endif %}

View File

@@ -0,0 +1,86 @@
<?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;
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class RoleDumper
{
public function __construct(
private RoleProvider $roleProvider,
private RoleHierarchyInterface $roleHierarchy,
private TranslatorInterface $translator,
) {}
public function dumpAsMarkdown(): string
{
$roles = $this->roleProvider->getRoles();
$rolesWithoutScopes = $this->roleProvider->getRolesWithoutScopes();
// Group roles by title
$groups = [];
foreach ($roles as $role) {
$title = $this->roleProvider->getRoleTitle($role);
$title ??= 'Other';
$groups[$title][] = $role;
}
// Sort groups by title
ksort($groups, SORT_NATURAL | SORT_FLAG_CASE);
$lines = [];
foreach ($groups as $title => $roleList) {
// Sort roles by translated label for deterministic output
usort($roleList, function (string $a, string $b): int {
$ta = $this->translator->trans($a);
$tb = $this->translator->trans($b);
return strcasecmp($ta, $tb);
});
$translatedTitle = $this->translator->trans($title);
$lines[] = '## '.$translatedTitle;
foreach ($roleList as $role) {
// Translate primary role
$translatedRole = $this->translator->trans($role);
// Scope marker: (S) if needs scope, (~~S~~) if no scope required
$needsScope = !in_array($role, $rolesWithoutScopes, true);
$scopeMarker = $needsScope ? '(S)' : '(~~S~~)';
// Compute dependent roles from hierarchy (exclude itself)
$reachable = $this->roleHierarchy->getReachableRoleNames([$role]);
$dependents = array_values(array_filter($reachable, static fn (string $r): bool => $r !== $role));
// Translate dependents and sort deterministically
$translatedDependents = array_map(fn (string $r) => $this->translator->trans($r), $dependents);
sort($translatedDependents, SORT_NATURAL | SORT_FLAG_CASE);
if (count($translatedDependents) > 0) {
$lines[] = sprintf('- **%s** %s: %s', $translatedRole, $scopeMarker, implode(', ', $translatedDependents));
} else {
$lines[] = sprintf('- **%s** %s', $translatedRole, $scopeMarker);
}
}
// Add a blank line between groups
$lines[] = '';
}
// Trim possible trailing blank line
$markdown = rtrim(implode("\n", $lines));
return $markdown."\n"; // End with newline for POSIX friendliness
}
}

View File

@@ -52,12 +52,8 @@ class RoleProvider
/**
* Get the title for each role.
*
* @param string $role
*
* @return string the title of the role
*/
public function getRoleTitle($role)
public function getRoleTitle(string $role): ?string
{
$this->initializeRolesTitlesCache();
@@ -73,7 +69,7 @@ class RoleProvider
/**
* initialize the array for caching role and titles.
*/
private function initializeRolesTitlesCache()
private function initializeRolesTitlesCache(): void
{
// break if already initialized
if (null !== $this->rolesTitlesCache) {

View File

@@ -39,6 +39,8 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
'label' => '',
'email' => '',
'isAbsent' => false,
'absenceStart' => null,
'absenceEnd' => null,
];
public function __construct(private readonly UserRender $userRender, private readonly ClockInterface $clock) {}
@@ -77,6 +79,11 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
['docgen:expects' => PhoneNumber::class, 'groups' => 'docgen:read']
);
$absenceDatesContext = array_merge(
$context,
['docgen:expects' => \DateTimeImmutable::class, 'groups' => 'docgen:read']
);
if (null === $object && 'docgen' === $format) {
return [...self::NULL_USER, 'phonenumber' => $this->normalizer->normalize(null, $format, $phonenumberContext), 'civility' => $this->normalizer->normalize(null, $format, $civilityContext), 'user_job' => $this->normalizer->normalize(null, $format, $userJobContext), 'main_center' => $this->normalizer->normalize(null, $format, $centerContext), 'main_scope' => $this->normalizer->normalize(null, $format, $scopeContext), 'current_location' => $this->normalizer->normalize(null, $format, $locationContext), 'main_location' => $this->normalizer->normalize(null, $format, $locationContext)];
}
@@ -99,6 +106,8 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
'main_center' => $this->normalizer->normalize($object->getMainCenter(), $format, $centerContext),
'main_scope' => $this->normalizer->normalize($object->getMainScope($at), $format, $scopeContext),
'isAbsent' => $object->isAbsent(),
'absenceStart' => $this->normalizer->normalize($object->getAbsenceStart(), $format, $absenceDatesContext),
'absenceEnd' => $this->normalizer->normalize($object->getAbsenceEnd(), $format, $absenceDatesContext),
];
if ('docgen' === $format) {

View File

@@ -67,4 +67,36 @@ class UserTest extends TestCase
->first()->getEndDate()
);
}
public function testIsAbsent()
{
$user = new User();
// Absent: today is within absence period
$absenceStart = new \DateTimeImmutable('-1 day');
$absenceEnd = new \DateTimeImmutable('+1 day');
$user->setAbsenceStart($absenceStart);
$user->setAbsenceEnd($absenceEnd);
self::assertTrue($user->isAbsent(), 'Should be absent when now is between start and end');
// Absent: end is null
$user->setAbsenceStart(new \DateTimeImmutable('-2 days'));
$user->setAbsenceEnd(null);
self::assertTrue($user->isAbsent(), 'Should be absent when started and no end');
// Not absent: absenceStart is in the future
$user->setAbsenceStart(new \DateTimeImmutable('+2 days'));
$user->setAbsenceEnd(null);
self::assertFalse($user->isAbsent(), 'Should not be absent if start is in the future');
// Not absent: absenceEnd is in the past
$user->setAbsenceStart(new \DateTimeImmutable('-5 days'));
$user->setAbsenceEnd(new \DateTimeImmutable('-1 day'));
self::assertFalse($user->isAbsent(), 'Should not be absent if end is in the past');
// Not absent: both are null
$user->setAbsenceStart(null);
$user->setAbsenceEnd(null);
self::assertFalse($user->isAbsent(), 'Should not be absent if start is null');
}
}

View File

@@ -0,0 +1,98 @@
<?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\Security;
use Chill\MainBundle\Security\ProvideRoleHierarchyInterface;
use Chill\MainBundle\Security\RoleDumper;
use Chill\MainBundle\Security\RoleProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @internal
*
* @coversNothing
*/
class RoleDumperTest extends TestCase
{
public function testDumpAsMarkdownGroupsByTitleTranslatesAndListsDependencies(): void
{
// Fake provider with two groups
$provider = new class () implements ProvideRoleHierarchyInterface {
public const R_PERSON_SEE = 'CHILL_PERSON_SEE';
public const R_PERSON_UPDATE = 'CHILL_PERSON_UPDATE';
public const R_REPORT_SEE = 'CHILL_REPORT_SEE';
public function getRoles(): array
{
return [self::R_PERSON_SEE, self::R_PERSON_UPDATE, self::R_REPORT_SEE];
}
public function getRolesWithoutScope(): array
{
// In this test, assume REPORT_SEE does not need scope, others do
return [self::R_REPORT_SEE];
}
public function getRolesWithHierarchy(): array
{
return [
'Person' => [self::R_PERSON_SEE, self::R_PERSON_UPDATE],
'Report' => [self::R_REPORT_SEE],
];
}
};
$roleProvider = new RoleProvider([$provider]);
// Fake role hierarchy: UPDATE implies SEE; others none
$roleHierarchy = new class () implements RoleHierarchyInterface {
public function getReachableRoleNames(array $roles): array
{
$output = [];
foreach ($roles as $r) {
$output[] = $r;
if ('CHILL_PERSON_UPDATE' === $r) {
$output[] = 'CHILL_PERSON_SEE';
}
}
return array_values(array_unique($output));
}
};
// Fake translator that clearly shows translation applied
$translator = new class () implements TranslatorInterface {
public function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string
{
return 'T('.$id.')';
}
public function getLocale(): string
{
return 'en';
}
};
$dumper = new RoleDumper($roleProvider, $roleHierarchy, $translator);
$md = $dumper->dumpAsMarkdown();
$expected = "## T(Person)\n"
."- **T(CHILL_PERSON_SEE)** (S)\n"
."- **T(CHILL_PERSON_UPDATE)** (S): T(CHILL_PERSON_SEE)\n\n"
."## T(Report)\n"
."- **T(CHILL_REPORT_SEE)** (~~S~~)\n";
self::assertSame($expected, $md);
}
}

View File

@@ -101,6 +101,8 @@ final class UserNormalizerTest extends TestCase
'text_without_absent' => 'SomeUser',
'isAbsent' => false,
'main_center' => ['context' => Center::class],
'absenceStart' => ['context' => \DateTimeImmutable::class],
'absenceEnd' => ['context' => \DateTimeImmutable::class],
]];
yield [$userNoPhone, 'docgen', ['docgen:expects' => User::class],
@@ -120,6 +122,8 @@ final class UserNormalizerTest extends TestCase
'text_without_absent' => 'AnotherUser',
'isAbsent' => false,
'main_center' => ['context' => Center::class],
'absenceStart' => ['context' => \DateTimeImmutable::class],
'absenceEnd' => ['context' => \DateTimeImmutable::class],
]];
yield [null, 'docgen', ['docgen:expects' => User::class], [
@@ -138,6 +142,8 @@ final class UserNormalizerTest extends TestCase
'text_without_absent' => '',
'isAbsent' => false,
'main_center' => ['context' => Center::class],
'absenceStart' => null,
'absenceEnd' => null,
]];
}
}

View File

@@ -80,3 +80,7 @@ services:
Chill\MainBundle\Command\SynchronizeEntityInfoViewsCommand:
tags:
- {name: console.command}
Chill\MainBundle\Command\DumpListPermissionsCommand:
autoconfigure: true
autowire: true

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\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250722140048 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add an absence end date for the user absence';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE users ADD absenceEnd TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN users.absenceEnd IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE users DROP absenceEnd');
}
}

View File

@@ -136,3 +136,7 @@ filter_order:
Search: Chercher dans la liste
By date: Filtrer par date
search_box: Filtrer par contenu
absence:
You are listed as absent, as of {date, date, short}: Votre absence est indiquée à partir du {date, date, short}

View File

@@ -841,12 +841,12 @@ absence:
# single letter for absence
A: A
My absence: Mon absence
Unset absence: Supprimer la date d'absence
Unset absence: Supprimer mes dates d'absence
Set absence date: Indiquer une date d'absence
Absence start: Absent à partir du
Absence end: Jusqu'au
Absent: Absent
You are marked as being absent: Vous êtes indiqué absent.
You are listed as absent, as of: Votre absence est indiquée à partir du
No absence listed: Aucune absence indiquée.
Is absent: Absent?

View File

@@ -40,3 +40,7 @@ workflow:
rolling_date:
When fixed date is selected, you must provide a date: Indiquez la date fixe choisie
user:
absence_end_requires_start: "Vous ne pouvez pas renseigner une date de fin d'absence sans date de début."

View File

@@ -15,6 +15,7 @@ use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterfac
use Chill\PersonBundle\Actions\Remove\PersonMoveSqlHandlerInterface;
use Chill\PersonBundle\DependencyInjection\CompilerPass\AccompanyingPeriodTimelineCompilerPass;
use Chill\PersonBundle\Export\Helper\CustomizeListPersonHelperInterface;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
use Chill\PersonBundle\Service\EntityInfo\AccompanyingPeriodInfoUnionQueryPartInterface;
use Chill\PersonBundle\Widget\PersonListWidgetFactory;
use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -38,5 +39,7 @@ class ChillPersonBundle extends Bundle
->addTag('chill_person.list_person_customizer');
$container->registerForAutoconfiguration(NotificationFlagProviderInterface::class)
->addTag('chill_main.notification_flag_provider');
$container->registerForAutoconfiguration(PersonIdentifierEngineInterface::class)
->addTag('chill_person.person_identifier_engine');
}
}

View File

@@ -78,7 +78,7 @@ class AccompanyingPeriodWorkDuplicateController extends AbstractController
* @ParamConverter("acpw1", options={"id": "acpw1_id"})
* @ParamConverter("acpw2", options={"id": "acpw2_id"})
*/
#[Route(path: '/{_locale}/person/{acpw1_id}/duplicate/{acpw2_id}/confirm', name: 'chill_person_acpw_duplicate_confirm')]
#[Route(path: '/{_locale}/person/{acpw1_id}/acpw-duplicate/{acpw2_id}/confirm', name: 'chill_person_acpw_duplicate_confirm')]
public function confirmAction(AccompanyingPeriodWork $acpw1, AccompanyingPeriodWork $acpw2, Request $request)
{
$accompanyingPeriod = $acpw1->getAccompanyingPeriod();

View File

@@ -17,7 +17,6 @@ use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Form\CreationPersonType;
use Chill\PersonBundle\Form\PersonType;
use Chill\PersonBundle\Privacy\PrivacyEvent;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Search\SimilarPersonMatcher;
@@ -49,56 +48,6 @@ final class PersonController extends AbstractController
private readonly EntityManagerInterface $em,
) {}
#[Route(path: '/{_locale}/person/{person_id}/general/edit', name: 'chill_person_general_edit')]
public function editAction(int $person_id, Request $request)
{
$person = $this->_getPerson($person_id);
if (null === $person) {
throw $this->createNotFoundException();
}
$this->denyAccessUnlessGranted(
'CHILL_PERSON_UPDATE',
$person,
'You are not allowed to edit this person'
);
$form = $this->createForm(
PersonType::class,
$person,
[
'cFGroup' => $this->getCFGroup(),
]
);
$form->handleRequest($request);
if ($form->isSubmitted() && !$form->isValid()) {
$this->get('session')
->getFlashBag()->add('error', $this->translator
->trans('This form contains errors'));
} elseif ($form->isSubmitted() && $form->isValid()) {
$this->em->flush();
$this->get('session')->getFlashBag()
->add(
'success',
$this->translator
->trans('The person data has been updated')
);
return $this->redirectToRoute('chill_person_view', [
'person_id' => $person->getId(),
]);
}
return $this->render(
'@ChillPerson/Person/edit.html.twig',
['person' => $person, 'form' => $form->createView()]
);
}
public function getCFGroup()
{
$cFGroup = null;

View File

@@ -0,0 +1,79 @@
<?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\PersonBundle\Controller;
use Chill\CustomFieldsBundle\EntityRepository\CustomFieldsDefaultGroupRepository;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Form\PersonType;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Doctrine\ORM\EntityManagerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
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\Session;
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\Translation\TranslatableMessage;
use Twig\Environment;
final readonly class PersonEditController
{
public function __construct(
private Security $security,
private FormFactoryInterface $formFactory,
private CustomFieldsDefaultGroupRepository $customFieldsDefaultGroupRepository,
private EntityManagerInterface $entityManager,
private UrlGeneratorInterface $urlGenerator,
private Environment $twig,
) {}
/**
* @ParamConverter("person", options={"id": "person_id"})
*/
#[Route(path: '/{_locale}/person/{person_id}/general/edit', name: 'chill_person_general_edit')]
public function editAction(Person $person, Request $request, Session $session)
{
if (!$this->security->isGranted(PersonVoter::UPDATE, $person)) {
throw new AccessDeniedHttpException('You are not allowed to edit this person.');
}
$form = $this->formFactory->create(
PersonType::class,
$person,
['cFGroup' => $this->customFieldsDefaultGroupRepository->findOneByEntity(Person::class)?->getCustomFieldsGroup()]
);
$form->handleRequest($request);
if ($form->isSubmitted() && !$form->isValid()) {
$session
->getFlashBag()->add('error', new TranslatableMessage('This form contains errors'));
} elseif ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->flush();
$session->getFlashBag()->add('success', new TranslatableMessage('The person data has been updated'));
return new RedirectResponse(
$this->urlGenerator->generate('chill_person_view', ['person_id' => $person->getId()])
);
}
return new Response($this->twig->render('@ChillPerson/Person/edit.html.twig', [
'form' => $form->createView(),
'person' => $person,
]));
}
}

View File

@@ -110,6 +110,24 @@ class Configuration implements ConfigurationInterface
->end()
->end() // children for 'person_fields', parent = array 'person_fields'
->end() // person_fields, parent = children of root
->arrayNode('person_render')
->addDefaultsIfNotSet()
->children()
->scalarNode('id_content_text')
->defaultValue('n°[[ person_id ]]')
->info(
<<<'EOF'
The way we display the person's id. Variables availables: "[[ person_id ]]", or, for person's
identifier: "[[ identifier_xx ]]" where xx is the identifier's definition's id.
There are also conditions available: "[[ if:identifier_yy ]] [[ identifier_yy ]] [[ endif:identifier_yy ]]"
Take care of keeping exactly one space between "[[" and the placeholder's content, and exactly one space before "]]"
EOF
)
->end()
->end() // end of person_render's children
->end() // end of person_render
->arrayNode('household_fields')
->canBeDisabled()
->children()

View File

@@ -0,0 +1,83 @@
<?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\PersonBundle\Entity\Identifier;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'chill_person_identifier')]
class PersonIdentifier
{
#[ORM\Id]
#[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)]
#[ORM\GeneratedValue]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Person::class)]
#[ORM\JoinColumn(name: 'person_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Person $person = null;
#[ORM\Column(name: 'value', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])]
private array $value = [];
#[ORM\Column(name: 'canonical', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => '[]'])]
private string $canonical = '';
public function __construct(
#[ORM\ManyToOne(targetEntity: PersonIdentifierDefinition::class)]
#[ORM\JoinColumn(name: 'definition_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private PersonIdentifierDefinition $definition,
) {}
public function getId(): ?int
{
return $this->id;
}
public function setPerson(?Person $person): self
{
$this->person = $person;
return $this;
}
public function getPerson(): Person
{
return $this->person;
}
public function getValue(): array
{
return $this->value;
}
public function setValue(array $value): void
{
$this->value = $value;
}
public function getCanonical(): string
{
return $this->canonical;
}
public function setCanonical(string $canonical): void
{
$this->canonical = $canonical;
}
public function getDefinition(): PersonIdentifierDefinition
{
return $this->definition;
}
}

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\PersonBundle\Entity\Identifier;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'chill_person_identifier_definition')]
class PersonIdentifierDefinition
{
#[ORM\Id]
#[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)]
#[ORM\GeneratedValue]
private ?int $id = null;
#[ORM\Column(name: 'active', type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => true])]
private bool $active = true;
public function __construct(
#[ORM\Column(name: 'label', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])]
private array $label,
#[ORM\Column(name: 'engine', type: \Doctrine\DBAL\Types\Types::STRING, length: 100)]
private string $engine,
#[ORM\Column(name: 'is_searchable', type: \Doctrine\DBAL\Types\Types::BOOLEAN, options: ['default' => false])]
private bool $isSearchable = false,
#[ORM\Column(name: 'is_editable_by_users', type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => false])]
private bool $isEditableByUsers = false,
#[ORM\Column(name: 'data', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])]
private array $data = [],
) {}
public function getId(): ?int
{
return $this->id;
}
public function getLabel(): array
{
return $this->label;
}
public function setLabel(array $label): void
{
$this->label = $label;
}
public function getEngine(): string
{
return $this->engine;
}
public function setEngine(string $engine): void
{
$this->engine = $engine;
}
public function isSearchable(): bool
{
return $this->isSearchable;
}
public function setIsSearchable(bool $isSearchable): void
{
$this->isSearchable = $isSearchable;
}
public function isEditableByUsers(): bool
{
return $this->isEditableByUsers;
}
public function setIsEditableByUsers(bool $isEditableByUsers): void
{
$this->isEditableByUsers = $isEditableByUsers;
}
public function isActive(): bool
{
return $this->active;
}
public function setActive(bool $active): self
{
$this->active = $active;
return $this;
}
public function getData(): array
{
return $this->data;
}
public function setData(array $data): void
{
$this->data = $data;
}
}

View File

@@ -31,6 +31,7 @@ use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Entity\Household\PersonHouseholdAddress;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Person\PersonCenterCurrent;
use Chill\PersonBundle\Entity\Person\PersonCenterHistory;
use Chill\PersonBundle\Entity\Person\PersonCurrentAddress;
@@ -271,6 +272,9 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
#[ORM\GeneratedValue(strategy: 'AUTO')]
private ?int $id = null;
#[ORM\OneToMany(mappedBy: 'person', targetEntity: PersonIdentifier::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $identifiers;
/**
* The person's last name.
*/
@@ -418,6 +422,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
$this->resources = new ArrayCollection();
$this->centerHistory = new ArrayCollection();
$this->signatures = new ArrayCollection();
$this->identifiers = new ArrayCollection();
}
public function __toString(): string
@@ -498,6 +503,24 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
return $this;
}
public function addIdentifier(PersonIdentifier $identifier): self
{
if (!$this->identifiers->contains($identifier)) {
$this->identifiers[] = $identifier;
$identifier->setPerson($this);
}
return $this;
}
public function removeIdentifier(PersonIdentifier $identifier): self
{
$this->identifiers->removeElement($identifier);
$identifier->setPerson(null);
return $this;
}
public function removeSignature(EntityWorkflowStepSignature $signature): self
{
$this->signatures->removeElement($signature);
@@ -1129,6 +1152,14 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
return $this->id;
}
/**
* @return ReadableCollection<int, PersonIdentifier>
*/
public function getIdentifiers(): ReadableCollection
{
return $this->identifiers;
}
/**
* @return string
*/
@@ -1262,6 +1293,22 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
return $this->spokenLanguages;
}
public function addSpokenLanguage(Language $language): self
{
if (!$this->spokenLanguages->contains($language)) {
$this->spokenLanguages->add($language);
}
return $this;
}
public function removeSpokenLanguage(Language $language): self
{
$this->spokenLanguages->removeElement($language);
return $this;
}
public function getUpdatedAt(): ?\DateTimeInterface
{
return $this->updatedAt;

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\PersonBundle\Form\DataMapper;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\PersonIdentifier\Exception\UnexpectedTypeException;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Chill\PersonBundle\Repository\Identifier\PersonIdentifierDefinitionRepository;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\FormInterface;
final readonly class PersonIdentifiersDataMapper implements DataMapperInterface
{
public function __construct(
private PersonIdentifierManagerInterface $identifierManager,
private PersonIdentifierDefinitionRepository $identifierDefinitionRepository,
) {}
public function mapDataToForms($viewData, \Traversable $forms): void
{
if (!$viewData instanceof Collection) {
throw new UnexpectedTypeException($viewData, Collection::class);
}
/** @var array<string, FormInterface> $formsByKey */
$formsByKey = iterator_to_array($forms);
foreach ($this->identifierManager->getWorkers() as $worker) {
if (!$worker->getDefinition()->isEditableByUsers()) {
continue;
}
$form = $formsByKey['identifier_'.$worker->getDefinition()->getId()];
$identifier = $viewData->findFirst(fn (int $key, PersonIdentifier $identifier) => $worker->getDefinition()->getId() === $identifier->getId());
if (null === $identifier) {
$identifier = new PersonIdentifier($worker->getDefinition());
}
$form->setData($identifier->getValue());
}
}
public function mapFormsToData(\Traversable $forms, &$viewData): void
{
if (!$viewData instanceof Collection) {
throw new UnexpectedTypeException($viewData, Collection::class);
}
foreach ($forms as $name => $form) {
$identifierId = (int) substr((string) $name, 11);
$identifier = $viewData->findFirst(fn (int $key, PersonIdentifier $identifier) => $identifier->getId() === $identifierId);
$definition = $this->identifierDefinitionRepository->find($identifierId);
if (null === $identifier) {
$identifier = new PersonIdentifier($definition);
$viewData->add($identifier);
}
if (!$identifier->getDefinition()->isEditableByUsers()) {
continue;
}
$worker = $this->identifierManager->buildWorkerByPersonIdentifierDefinition($definition);
$identifier->setValue($form->getData());
$identifier->setCanonical($worker->canonicalizeValue($identifier->getValue()));
}
}
}

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\PersonBundle\Form;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Form\DataMapper\PersonIdentifiersDataMapper;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
final class PersonIdentifiersType extends AbstractType
{
public function __construct(
private readonly PersonIdentifierManagerInterface $identifierManager,
private readonly PersonIdentifiersDataMapper $identifiersDataMapper,
private readonly TranslatableStringHelperInterface $translatableStringHelper,
) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
foreach ($this->identifierManager->getWorkers() as $worker) {
if (!$worker->getDefinition()->isEditableByUsers()) {
continue;
}
$subBuilder = $builder->create(
'identifier_'.$worker->getDefinition()->getId(),
options: [
'compound' => true,
'label' => $this->translatableStringHelper->localize($worker->getDefinition()->getLabel()),
]
);
$worker->buildForm($subBuilder);
$builder->add($subBuilder);
}
$builder->setDataMapper($this->identifiersDataMapper);
}
}

View File

@@ -72,8 +72,8 @@ class PersonType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('firstName')
->add('lastName')
->add('firstName', TextType::class, ['empty_data' => ''])
->add('lastName', TextType::class, ['empty_data' => ''])
->add('birthdate', ChillDateType::class, [
'required' => false,
])
@@ -101,7 +101,7 @@ class PersonType extends AbstractType
if ('visible' === $this->config['memo']) {
$builder
->add('memo', ChillTextareaType::class, ['required' => false]);
->add('memo', ChillTextareaType::class, ['required' => false, 'empty_data' => '']);
}
if ('visible' === $this->config['employment_status']) {
@@ -118,6 +118,7 @@ class PersonType extends AbstractType
$builder->add('placeOfBirth', TextType::class, [
'required' => false,
'attr' => ['style' => 'text-transform: uppercase;'],
'empty_data' => '',
]);
$builder->get('placeOfBirth')->addModelTransformer(new CallbackTransformer(
@@ -127,7 +128,9 @@ class PersonType extends AbstractType
}
if ('visible' === $this->config['contact_info']) {
$builder->add('contactInfo', ChillTextareaType::class, ['required' => false]);
$builder->add('contactInfo', ChillTextareaType::class, [
'required' => false, 'empty_data' => '', 'label' => 'Notes on contact information',
]);
}
if ('visible' === $this->config['phonenumber']) {
@@ -152,12 +155,12 @@ class PersonType extends AbstractType
'required' => false,
]
)
->add('acceptSMS', CheckboxType::class, [
->add('acceptSms', CheckboxType::class, [
'required' => false,
]);
}
$builder->add('otherPhoneNumbers', ChillCollectionType::class, [
$builder->add('otherPhonenumbers', ChillCollectionType::class, [
'entry_type' => PersonPhoneType::class,
'button_add_label' => 'Add new phone',
'button_remove_label' => 'Remove phone',
@@ -173,12 +176,12 @@ class PersonType extends AbstractType
if ('visible' === $this->config['email']) {
$builder
->add('email', EmailType::class, ['required' => false]);
->add('email', EmailType::class, ['required' => false, 'empty_data' => '']);
}
if ('visible' === $this->config['acceptEmail']) {
$builder
->add('acceptEmail', CheckboxType::class, ['required' => false]);
->add('acceptEmail', CheckboxType::class, ['required' => false, 'empty_data' => '']);
}
if ('visible' === $this->config['country_of_birth']) {
@@ -222,6 +225,10 @@ class PersonType extends AbstractType
]);
}
$builder->add('identifiers', PersonIdentifiersType::class, [
'by_reference' => false,
]);
if ($options['cFGroup']) {
$builder
->add(
@@ -232,10 +239,7 @@ class PersonType extends AbstractType
}
}
/**
* @param OptionsResolverInterface $resolver
*/
public function configureOptions(OptionsResolver $resolver)
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Person::class,
@@ -251,10 +255,7 @@ class PersonType extends AbstractType
);
}
/**
* @return string
*/
public function getBlockPrefix()
public function getBlockPrefix(): string
{
return 'chill_personbundle_person';
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\PersonIdentifier\Exception;
class EngineNotFoundException extends \RuntimeException
{
public function __construct(string $name)
{
parent::__construct("Engine for EngineInterface not found: {$name}");
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\PersonIdentifier\Exception;
class PersonIdentifierDefinitionNotFoundException extends \RuntimeException
{
public function __construct(int $id, ?\Throwable $previous = null)
{
parent::__construct("Person identifier definition not found by his id: {$id}", previous: $previous);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\PersonIdentifier\Exception;
class UnexpectedTypeException extends \InvalidArgumentException
{
public function __construct(mixed $value, string $expectedType)
{
parent::__construct(\sprintf('Expected argument of type "%s", "%s" given', $expectedType, get_debug_type($value)));
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\PersonIdentifier\Identifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
final readonly class StringIdentifier implements PersonIdentifierEngineInterface
{
public static function getName(): string
{
return 'chill-person-bundle.string-identifier';
}
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
{
return $value['content'] ?? '';
}
public function buildForm(FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void
{
$builder->add('content', TextType::class, ['label' => false]);
}
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
{
return $identifier?->getValue()['content'] ?? '';
}
}

View File

@@ -0,0 +1,27 @@
<?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\PersonBundle\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Symfony\Component\Form\FormBuilderInterface;
interface PersonIdentifierEngineInterface
{
public static function getName(): string;
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string;
public function buildForm(FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void;
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string;
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Chill\PersonBundle\PersonIdentifier\Exception\EngineNotFoundException;
use Chill\PersonBundle\Repository\Identifier\PersonIdentifierDefinitionRepository;
final readonly class PersonIdentifierManager implements PersonIdentifierManagerInterface
{
public function __construct(
private iterable $engines,
private PersonIdentifierDefinitionRepository $personIdentifierDefinitionRepository,
) {}
/**
* Build PersonIdentifierWorker's for all active definition.
*
* @return list<PersonIdentifierWorker>
*/
public function getWorkers(): array
{
$workers = [];
foreach ($this->personIdentifierDefinitionRepository->findByActive() as $definition) {
try {
$worker = $this->getEngine($definition->getEngine());
} catch (EngineNotFoundException) {
continue;
}
$workers[] = new PersonIdentifierWorker($worker, $definition);
}
return $workers;
}
public function buildWorkerByPersonIdentifierDefinition(PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker
{
return new PersonIdentifierWorker($this->getEngine($personIdentifierDefinition->getEngine()), $personIdentifierDefinition);
}
/**
* @throw EngineNotFoundException
*/
private function getEngine(string $name): PersonIdentifierEngineInterface
{
foreach ($this->engines as $engine) {
if ($engine->getName() === $name) {
return $engine;
}
}
throw new EngineNotFoundException($name);
}
}

View File

@@ -0,0 +1,26 @@
<?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\PersonBundle\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
interface PersonIdentifierManagerInterface
{
/**
* Build PersonIdentifierWorker's for all active definition.
*
* @return list<PersonIdentifierWorker>
*/
public function getWorkers(): array;
public function buildWorkerByPersonIdentifierDefinition(PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker;
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Symfony\Component\Form\FormBuilderInterface;
final readonly class PersonIdentifierWorker
{
public function __construct(
private PersonIdentifierEngineInterface $identifierEngine,
private PersonIdentifierDefinition $definition,
) {}
public function getIdentifierEngine(): PersonIdentifierEngineInterface
{
return $this->identifierEngine;
}
public function getDefinition(): PersonIdentifierDefinition
{
return $this->definition;
}
public function buildForm(FormBuilderInterface $builder): void
{
$this->identifierEngine->buildForm($builder, $this->definition);
}
public function canonicalizeValue(array $value): ?string
{
return $this->identifierEngine->canonicalizeValue($value, $this->definition);
}
public function renderAsString(?PersonIdentifier $identifier): string
{
return $this->identifierEngine->renderAsString($identifier, $this->definition);
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\PersonIdentifier\Rendering;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
final readonly class PersonIdRendering implements PersonIdRenderingInterface
{
private string $idContentText;
public function __construct(
ParameterBagInterface $parameterBag,
private PersonIdentifierManagerInterface $personIdentifierManager,
) {
$this->idContentText = $parameterBag->get('chill_person')['person_render']['id_content_text'];
}
public function renderPersonId(Person $person): string
{
$args = [
'[[ person_id ]]' => $person->getId(),
];
foreach ($person->getIdentifiers() as $identifier) {
if (!$identifier->getDefinition()->isActive()) {
continue;
}
$key = 'identifier_'.$identifier->getDefinition()->getId();
$args
+= [
"[[ {$key} ]]" => $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($identifier->getDefinition())
->renderAsString($identifier),
"[[ if:{$key} ]]" => '',
"[[ endif:{$key} ]]" => '',
];
// we remove the eventual conditions
}
$rendered = strtr($this->idContentText, $args);
// Delete the conditions which are not met, for instance:
// [[ if:identifier_99 ]] ... [[ endif:identifier_99 ]]
// this match the same dumber for opening and closing of the condition
return preg_replace(
'/\[\[\s*if:identifier_(\d+)\s*\]\].*?\[\[\s*endif:identifier_\1\s*\]\]/s',
'',
$rendered
);
}
}

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\PersonBundle\PersonIdentifier\Rendering;
use Chill\PersonBundle\Entity\Person;
interface PersonIdRenderingInterface
{
public function renderPersonId(Person $person): string;
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\PersonIdentifier\Rendering;
use Chill\PersonBundle\Entity\Person;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
final class PersonIdRenderingTwigExtension extends AbstractExtension
{
public function __construct(private readonly PersonIdRenderingInterface $personIdRendering) {}
public function getFilters(): array
{
return [
new TwigFilter(
'chill_person_id_render_text',
fn (Person $person): string => $this->personIdRendering->renderPersonId($person)
),
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\PersonIdentifier\Rendering;
use Chill\MainBundle\Templating\Entity\ChillEntityRenderInterface;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
/**
* @template-implements ChillEntityRenderInterface<PersonIdentifier>
*/
final readonly class PersonIdentifierEntityRender implements ChillEntityRenderInterface
{
public function __construct(private PersonIdentifierManagerInterface $identifierManager) {}
public function renderBox(mixed $entity, array $options): string
{
return $this->renderString($entity, $options);
}
public function renderString(mixed $entity, array $options): string
{
$worker = $this->identifierManager->buildWorkerByPersonIdentifierDefinition($entity->getDefinition());
return $worker->renderAsString($entity);
}
public function supports(object $entity, array $options): bool
{
return $entity instanceof PersonIdentifier;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Repository\Identifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @template-extends ServiceEntityRepository<PersonIdentifierDefinition>
*/
class PersonIdentifierDefinitionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $managerRegistry)
{
parent::__construct($managerRegistry, PersonIdentifierDefinition::class);
}
public function findByActive(): array
{
return $this->findBy(['active' => true]);
}
}

View File

@@ -281,11 +281,6 @@ abbr.referrer { // still used ?
font-style: italic;
}
.created-updated {
border: 1px solid black;
padding: 10px;
}
/// Masonry blocs on AccompanyingCourse resume page
div#dashboards {
div.mbloc {

View File

@@ -8,7 +8,7 @@
<h1>
<i class="fa fa-random fa-fw"></i>
{{ 'Accompanying Course'|trans }}
<span class="id-number">{{ accompanyingCourse.id }}</span>
<span class="id-number">({{ 'accompanying_period.number'|trans({ 'id': accompanyingCourse.id}) }})</span>
</h1>
</div>
</div>

View File

@@ -78,11 +78,6 @@
{%- if options['addEntity'] -%}
<span class="badge rounded-pill bg-secondary">{{ 'Person'|trans }}</span>
{%- endif -%}
{%- if options['addId'] -%}
<span class="id-number" title="{{ 'Person'|trans ~ ' n° ' ~ person.id }}">
{{ person.id|upper -}}
</span>
{%- endif -%}
</div>
{%- if options['addInfo'] -%}
<p class="moreinfo">
@@ -99,6 +94,12 @@
{%- if options['addAge'] -%}
<span class="age">&nbsp;{{ 'years_old'|trans({ 'age': person.age }) }}</span>
{%- endif -%}
{%- if options['addId'] -%}
{%- set personId = person|chill_person_id_render_text %}
<span class="id-number" title="{{ 'Person'|trans ~ ' ' ~ personId }}">
({{ personId }})
</span>
{%- endif -%}
{%- elseif person.birthdate is not null -%}
<time datetime="{{ person.birthdate|date('Y-m-d') }}" title="{{ 'Birthdate'|trans }}">
{{ 'Born the date'|trans({'gender': person.gender ? person.gender.genderTranslation.value : 'neutral',
@@ -108,6 +109,12 @@
<span class="age">{{- 'years_old'|trans({ 'age': person.age }) -}}</span>
{%- endif -%}
{%- endif -%}
{%- if options['addId'] -%}
{%- set personId = person|chill_person_id_render_text %}
<span class="id-number same-size" title="{{ 'Person'|trans ~ ' ' ~ personId }}">
({{ personId }})
</span>
{%- endif -%}
</p>
{%- endif -%}
{#- tricks to remove easily whitespace after template -#}

View File

@@ -31,7 +31,7 @@
{% if form.memo is defined %}
<fieldset>
<legend><h2>{{ 'Memo'|trans }}</h2></legend>
{{ form_row(form.memo, {'label' : 'Memo'} ) }}
{{ form_widget(form.memo, {'label' : 'Memo'} ) }}
</fieldset>
{% endif %}
@@ -85,15 +85,17 @@
{{ form_row(form.mobilenumber, {'label': 'Mobilenumber'}) }}
</div>
<div id="personAcceptSMS">
{{ form_row(form.acceptSMS, {'label' : 'Accept short text message ?'}) }}
{{ form_row(form.acceptSms, {'label' : 'Accept short text message ?'}) }}
</div>
{%- endif -%}
{%- if form.otherPhoneNumbers is defined -%}
{{ form_widget(form.otherPhoneNumbers) }}
{{ form_errors(form.otherPhoneNumbers) }}
{%- if form.otherPhonenumbers is defined -%}
{{ form_widget(form.otherPhonenumbers) }}
{{ form_errors(form.otherPhonenumbers) }}
{%- endif -%}
{%- if form.contactInfo is defined -%}
{{ form_row(form.contactInfo, {'label': 'Notes on contact information'}) }}
{{ form_label(form.contactInfo) }}
{{ form_widget(form.contactInfo) }}
{{ form_errors(form.contactInfo) }}
{%- endif -%}
</fieldset>
{%- endif -%}
@@ -134,6 +136,20 @@
</fieldset>
{%- endif -%}
{% if form.identifiers|length > 0 %}
<fieldset>
<legend><h2>{{ 'person.Identifiers'|trans }}</h2></legend>
<div>
{% for f in form.identifiers %}
{{ form_row(f) }}
{% endfor %}
</div>
</fieldset>
{% else %}
{{ form_widget(form.identifiers) }}
{% endif %}
{{ form_rest(form) }}
<ul class="record_actions sticky-form-buttons">

View File

@@ -1,19 +1,3 @@
{#
* Copyright (C) 2014, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
{% extends "@ChillPerson/Person/layout.html.twig" %}
{% set activeRouteKey = 'chill_person_view' %}
@@ -78,6 +62,16 @@ This view should receive those arguments:
{% else %}
<dd>{{ 'gender.not defined'|trans }}</dd>
{% endif %}
{% if person.genderComment.comment is not empty %}
<dt>{{ 'Gender comment'|trans }}&nbsp;:</dt>
<dd>
<div class="chill-user-quote">
{{ person.genderComment.comment|chill_markdown_to_html }}
</div>
</dd>
{% endif %}
</dl>
</figure>
</div>
@@ -126,16 +120,6 @@ This view should receive those arguments:
</figure>
</div>
{% if person.genderComment.comment is not empty %}
<div class="col-12">
<figure class="person-details">
<h2 class="chill-beige">{{ 'Gender comment'|trans }}&nbsp;:</h2>
<div class="chill-user-quote">
{{ person.genderComment.comment|chill_markdown_to_html }}
</div>
</figure>
</div>
{% endif %}
</div>
<div class="row">
@@ -241,17 +225,20 @@ This view should receive those arguments:
<span class="chill-no-data-statement">{{ 'No data given'|trans }}</span>
{% endif %}
</dd>
<dt>{{ 'Comment on the marital status'|trans }}&nbsp;:</dt>
<dd>
{% if person.maritalStatusComment.comment is not empty %}
<blockquote class="chill-user-quote">
{{ person.maritalStatusComment.comment|chill_markdown_to_html }}
</blockquote>
{% else %}
<span class="chill-no-data-statement">{{ 'No data given'|trans }}</span>
{% if person.maritalStatusComment.comment is not empty %}
<dt>{{ 'Comment on the marital status'|trans }}&nbsp;:</dt>
<dd>
<blockquote class="chill-user-quote">
{{ person.maritalStatusComment.comment|chill_markdown_to_html }}
</blockquote>
</dd>
{% endif %}
{% for identifier in person.identifiers %}
{% if identifier.definition.isActive and (identifier|chill_entity_render_string) is not empty %}
<dt>{{ identifier.definition.label|localize_translatable_string }}&nbsp;:</dt>
<dd>{{ identifier|chill_entity_render_box }}</dd>
{% endif %}
</dd>
{% endfor %}
</dl>
{%- endif -%}
</figure>
@@ -341,7 +328,7 @@ This view should receive those arguments:
</div>
{% endif %}
<div class="created-updated">
<div>
{% if person.createdBy %}
<div class="createdBy">
{{ 'Created by'|trans}}: <b>{{ person.createdBy|chill_entity_render_box({'at_date': person.createdAt}) }}</b>,<br>

View File

@@ -23,7 +23,11 @@ class PersonRender implements PersonRenderInterface
{
use BoxUtilsChillEntityRenderTrait;
public function __construct(private readonly ConfigPersonAltNamesHelper $configAltNamesHelper, private readonly \Twig\Environment $engine, private readonly TranslatorInterface $translator) {}
public function __construct(
private readonly ConfigPersonAltNamesHelper $configAltNamesHelper,
private readonly \Twig\Environment $engine,
private readonly TranslatorInterface $translator,
) {}
public function renderBox($person, array $options): string
{

View File

@@ -0,0 +1,145 @@
<?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\PersonBundle\Tests\PersonIdentifier\Rendering;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\PersonIdentifier\Identifier\StringIdentifier;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManager;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
use Chill\PersonBundle\PersonIdentifier\Rendering\PersonIdRendering;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
* @internal
*
* @coversNothing
*/
class PersonIdRenderingTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider provideRenderCases
*/
public function testRenderPersonId(Person $person, string $idContentText, string $expected): void
{
// Parameter bag mock returning the provided id_content_text
$parameterBag = $this->prophesize(ParameterBagInterface::class);
$parameterBag->get('chill_person')
->willReturn(['person_render' => ['id_content_text' => $idContentText]]);
// PersonIdentifierManager is explicitly requested to be mocked in the spec.
// It will return a PersonIdentifierWorker whose renderAsString behaves like StringIdentifier::renderAsString
$personIdentifierManager = $this->prophesize(PersonIdentifierManagerInterface::class);
$personIdentifierManager
->buildWorkerByPersonIdentifierDefinition(Argument::type(PersonIdentifierDefinition::class))
->will(function ($args) {
/** @var PersonIdentifierDefinition $definition */
$definition = $args[0];
$engine = new class () implements PersonIdentifierEngineInterface {
public static function getName(): string
{
return 'test';
}
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
{
return $value['content'] ?? '';
}
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {}
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
{
// same behavior as StringIdentifier::renderAsString
return $identifier?->getValue()['content'] ?? '';
}
};
return new PersonIdentifierWorker($engine, $definition);
});
$service = new PersonIdRendering($parameterBag->reveal(), $personIdentifierManager->reveal());
self::assertSame($expected, $service->renderPersonId($person));
}
public function provideRenderCases(): iterable
{
// Case 1: one active identifier, one inactive identifier, should render person id and only active identifier
$person1 = new Person();
$this->setEntityId($person1, 123);
$defActive = new PersonIdentifierDefinition(label: ['en' => 'Active'], engine: 'string');
$this->setEntityId($defActive, 10);
$defActive->setActive(true);
$idActive = new PersonIdentifier($defActive);
$idActive->setPerson($person1);
$idActive->setValue(['content' => 'ABC']);
$person1->addIdentifier($idActive);
$defInactive = new PersonIdentifierDefinition(label: ['en' => 'Inactive'], engine: 'string');
$this->setEntityId($defInactive, 99);
$defInactive->setActive(false);
$idInactive = new PersonIdentifier($defInactive);
$idInactive->setPerson($person1);
$idInactive->setValue(['content' => 'SHOULD_NOT_APPEAR']);
$person1->addIdentifier($idInactive);
$template1 = 'ID: [[ person_id ]] - Active: [[ identifier_10 ]] - Inactive: [[ identifier_99 ]]';
$expected1 = 'ID: 123 - Active: ABC - Inactive: [[ identifier_99 ]]';
yield
'with active and inactive identifiers' => [$person1, $template1, $expected1]
;
$template2 = 'ID: [[ person_id ]][[ if:identifier_10 ]] - Active: [[ identifier_10 ]][[ endif:identifier_10 ]]';
$expected2 = 'ID: 123 - Active: ABC';
yield
'rendering with conditional: condition are removed' => [$person1, $template2, $expected2]
;
$template3 = 'ID: [[ person_id ]][[ if:identifier_99 ]] - Inactive: [[ identifier_10 ]][[ endif:identifier_99 ]]';
$expected3 = 'ID: 123';
yield
'rendering with conditional: the content between condition is removed' => [$person1, $template3, $expected3]
;
$template4 = 'ID: [[ person_id ]][[ if:identifier_105 ]] - not present: [[ identifier_105 ]][[ endif:identifier_105 ]]';
$expected4 = 'ID: 123';
yield
'rendering with conditional: the content between condition is removed, the identifier is not associated with the person' => [$person1, $template4, $expected4]
;
}
private function setEntityId(object $entity, int $id): void
{
$refl = new \ReflectionClass($entity);
$prop = $refl->getProperty('id');
$prop->setAccessible(true);
$prop->setValue($entity, $id);
}
}

View File

@@ -95,3 +95,16 @@ services:
Chill\PersonBundle\Service\EntityInfo\AccompanyingPeriodViewEntityInfoProvider:
arguments:
$unions: !tagged_iterator chill_person.accompanying_period_info_part
Chill\PersonBundle\PersonIdentifier\PersonIdentifierManager:
arguments:
$engines: !tagged_iterator chill_person.person_identifier_engine
Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface:
alias: Chill\PersonBundle\PersonIdentifier\PersonIdentifierManager
Chill\PersonBundle\PersonIdentifier\Identifier\:
resource: '../PersonIdentifier/Identifier'
Chill\PersonBundle\PersonIdentifier\Rendering\:
resource: '../PersonIdentifier/Rendering'

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Person;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250822123819 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add person identifier tables: chill_person_identifier_definition and chill_person_identifier with FKs to person and definition; create supporting sequences and indexes.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE SEQUENCE chill_person_identifier_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE SEQUENCE chill_person_identifier_definition_id_seq INCREMENT BY 1 MINVALUE 1 START 1000');
$this->addSql(
<<<'SQL'
CREATE TABLE chill_person_identifier (
id INT NOT NULL,
person_id INT NOT NULL,
definition_id INT NOT NULL,
value JSONB NOT NULL DEFAULT '[]'::jsonb,
canonical TEXT NOT NULL DEFAULT '',
PRIMARY KEY(id)
)
SQL
);
$this->addSql('CREATE INDEX IDX_BCA5A36B217BBB47 ON chill_person_identifier (person_id)');
$this->addSql('CREATE INDEX IDX_BCA5A36BD11EA911 ON chill_person_identifier (definition_id)');
$this->addSql(
<<<'SQL'
CREATE TABLE chill_person_identifier_definition (
id INT NOT NULL,
label JSON DEFAULT '[]' NOT NULL,
engine VARCHAR(100) NOT NULL,
is_searchable BOOLEAN DEFAULT false NOT NULL,
is_editable_by_users BOOLEAN DEFAULT false NOT NULL,
data JSONB DEFAULT '[]' NOT NULL,
active BOOLEAN DEFAULT true NOT NULL,
PRIMARY KEY(id))
SQL
);
$this->addSql('ALTER TABLE chill_person_identifier ADD CONSTRAINT FK_BCA5A36B217BBB47 FOREIGN KEY (person_id) REFERENCES chill_person_person (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_person_identifier ADD CONSTRAINT FK_BCA5A36BD11EA911 FOREIGN KEY (definition_id) REFERENCES chill_person_identifier_definition (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('DROP SEQUENCE chill_person_identifier_id_seq CASCADE');
$this->addSql('DROP SEQUENCE chill_person_identifier_definition_id_seq CASCADE');
$this->addSql('ALTER TABLE chill_person_identifier DROP CONSTRAINT FK_BCA5A36B217BBB47');
$this->addSql('ALTER TABLE chill_person_identifier DROP CONSTRAINT FK_BCA5A36BD11EA911');
$this->addSql('DROP TABLE chill_person_identifier');
$this->addSql('DROP TABLE chill_person_identifier_definition');
}
}

View File

@@ -21,6 +21,9 @@ accompanying_period:
other {Participants}
}
number: >-
n° {id}
person:
from_the: depuis le
And himself: >-

View File

@@ -102,6 +102,9 @@ spokenLanguages: Langues parlées
Employment status: Situation professionelle
Administrative status: Situation administrative
person:
Identifiers: Identifiants
# dédoublonnage
Old person: Doublon

View File

@@ -152,6 +152,17 @@
{% endif %}
</dl>
{% endblock %}
{% block content_view_actions_merge %}
<li>
<a href="{{ chill_path_add_return_path('chill_thirdparty_find_duplicate',
{ 'thirdparty_id': entity.id }) }}"
title="{{ 'Merge'|trans }}"
class="btn btn-misc">
<i class="bi bi-chevron-contract"></i>
{{ 'Merge'|trans }}
</a>
</li>
{% endblock %}
{% block content_form_actions_delete %}{% endblock %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% endembed %}