Compare commits

...

17 Commits

Author SHA1 Message Date
95d9a75e46 feat: add invitation list
- Introduced `MyInvitationsController` for managing user invitations
- Added `InviteACLAwareRepository` and its interface for handling invite data operations
- Created views for listing and displaying user-specific invitations
- Updated user menu to include "My invitations list" option
2025-09-05 16:10:51 +02: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
64 changed files with 1746 additions and 200 deletions

View File

@@ -0,0 +1,6 @@
kind: Feature
body: Create invitation list in user menu
time: 2025-08-08T12:08:02.446361367+02:00
custom:
Issue: "385"
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

3
.gitignore vendored
View File

@@ -18,6 +18,9 @@ migrations/*
templates/* templates/*
translations/* translations/*
# we allow developers to add customization on their installation, without commiting it
config/packages/dev/*
###> symfony/framework-bundle ### ###> symfony/framework-bundle ###
/.env.local /.env.local
/.env.local.php /.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 ## 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: 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`. - `/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 - `/docs/`: Contains project documentation
Each bundle typically has the following structure: Each bundle typically has the following structure:
@@ -46,13 +46,13 @@ Each bundle typically has the following structure:
### A special word about TicketBundle ### 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 ## Development Guidelines
### Building and Configuration Instructions ### 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`. 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 docker compose up -d
``` ```
5. **Set Up the Database**: 6. **Set Up the Database**:
```bash ```bash
# Create the database # Create the database
symfony console doctrine:database:create 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 symfony console doctrine:fixtures:load
``` ```
6. **Build Assets**: 7. **Build Assets**:
```bash ```bash
nvm use 20 nvm use 20
yarn run encore dev yarn run encore dev
``` ```
7. **Start the Development Server**: 8. **Start the Development Server**:
```bash ```bash
symfony server:start -d symfony server:start -d
``` ```
#### Docker Setup #### Docker Setup
The project includes Docker configuration for easier development: The project includes a Docker configuration for easier development:
1. **Start Docker Services**: 1. **Start Docker Services**:
```bash ```bash
@@ -153,9 +153,9 @@ Key configuration files:
Each time a doctrine entity is created, we generate migration to adapt the database. 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\ActivityBundle` writes migrations to `Chill\Migrations\Activity`;
- `Chill\Bundle\BudgetBundle` writes migrations to `Chill\Migrations\Budget`; - `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 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, `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` 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. 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). 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`; - `\Psr\Log\NullLogger`, an implementation of `\Psr\Log\LoggerInterface`;
- `\Symfony\Component\Clock\MockClock`, an implementation of `Symfony\Component\Clock\ClockInterface` (already mentioned above); - `\Symfony\Component\Clock\MockClock`, an implementation of `Symfony\Component\Clock\ClockInterface` (already mentioned above);
@@ -297,7 +297,7 @@ class TicketTest extends TestCase
#### Test Database #### 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 ### Code Quality Tools

View File

@@ -6,6 +6,24 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie). and is generated by [Changie](https://github.com/miniscruff/changie).
## 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 ## v4.1.0 - 2025-08-26
### Feature ### 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 * ([#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

@@ -266,7 +266,7 @@ class CalendarController extends AbstractController
} }
if (!$this->getUser() instanceof User) { if (!$this->getUser() instanceof User) {
throw new UnauthorizedHttpException('you are not an user'); throw new UnauthorizedHttpException('you are not a user');
} }
$view = '@ChillCalendar/Calendar/listByUser.html.twig'; $view = '@ChillCalendar/Calendar/listByUser.html.twig';

View File

@@ -0,0 +1,62 @@
<?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\CalendarBundle\Controller;
use Chill\CalendarBundle\Repository\InviteACLAwareRepository;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Routing\Annotation\Route;
class MyInvitationsController extends AbstractController
{
public function __construct(private readonly InviteACLAwareRepository $inviteACLAwareRepository, private readonly PaginatorFactory $paginator) {}
/**
* @throws NonUniqueResultException
* @throws NoResultException
*/
#[Route(path: '/{_locale}/calendar/invitations/my', name: 'chill_calendar_invitations_list_my')]
public function myInvitations(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$user = $this->getUser();
if (!$user instanceof User) {
throw new UnauthorizedHttpException('you are not a user');
}
$total = $this->inviteACLAwareRepository->countByUser($user);
$paginator = $this->paginator->create($total);
$invitations = $this->inviteACLAwareRepository->findByUser(
$user,
['createdAt' => 'DESC'],
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage()
);
dump($invitations);
$view = '@ChillCalendar/Invitations/listByUser.html.twig';
return $this->render($view, [
'invitations' => $invitations,
'paginator' => $paginator,
]);
}
}

View File

@@ -30,6 +30,13 @@ class UserMenuBuilder implements LocalMenuBuilderInterface
'order' => 9, 'order' => 9,
'icon' => 'tasks', 'icon' => 'tasks',
]); ]);
$menu->addChild('My invitations list', [
'route' => 'chill_calendar_invitations_list_my',
])
->setExtras([
'order' => 9,
'icon' => 'tasks',
]);
} }
} }

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\CalendarBundle\Repository;
use Chill\CalendarBundle\Entity\Invite;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
readonly class InviteACLAwareRepository implements InviteACLAwareRepositoryInterface
{
public function __construct(private EntityManagerInterface $em) {}
/**
* @throws NonUniqueResultException
* @throws NoResultException
*/
public function countByUser(User $user): int
{
return $this->buildQueryByUser($user)
->select('COUNT(i)')
->getQuery()
->getSingleScalarResult();
}
public function findByUser(User $user, ?array $orderBy = [], ?int $offset = null, ?int $limit = null): array
{
$qb = $this->buildQueryByUser($user)
->select('i');
foreach ($orderBy as $sort => $order) {
$qb->addOrderBy('i.'.$sort, $order);
}
if (null !== $offset) {
$qb->setFirstResult($offset);
}
if (null !== $limit) {
$qb->setMaxResults($limit);
}
return $qb->getQuery()->getResult();
}
public function buildQueryByUser(User $user): QueryBuilder
{
$qb = $this->em->createQueryBuilder()
->from(Invite::class, 'i');
$qb->where('i.user = :user');
$qb->setParameter('user', $user);
return $qb;
}
}

View File

@@ -0,0 +1,21 @@
<?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\CalendarBundle\Repository;
use Chill\MainBundle\Entity\User;
interface InviteACLAwareRepositoryInterface
{
public function countByUser(User $user): int;
public function findByUser(User $user, ?array $orderBy = [], ?int $offset = null, ?int $limit = null): array;
}

View File

@@ -0,0 +1,172 @@
{% if invitations|length > 0 %}
<div class="flex-table list-records">
{% for invitation in invitations %}
{% set calendar = invitation.getCalendar %}
{% if calendar is not null %}
<div class="item-bloc">
<div class="item-row main">
<div class="item-col">
<div class="wrap-header">
<div class="wl-row">
<div class="wl-col title">
<p class="date-label">
{% if calendar.endDate.diff(calendar.startDate).days >= 1 %}
{{ calendar.startDate|format_datetime('short', 'short') }}
- {{ calendar.endDate|format_datetime('short', 'short') }}
{% else %}
{{ calendar.startDate|format_datetime('short', 'short') }}
- {{ calendar.endDate|format_datetime('none', 'short') }}
{% endif %}
</p>
<div class="duration short-message">
<i class="fa fa-fw fa-hourglass-end"></i>
{{ calendar.duration|date('%H:%I') }}
{% if false == calendar.sendSMS or null == calendar.sendSMS %}
<!-- no sms will be send -->
{% else %}
{% if calendar.smsStatus == 'sms_sent' %}
<span title="{{ 'SMS already sent'|trans }}" class="badge bg-info">
<i class="fa fa-check "></i>
<i class="fa fa-envelope "></i>
</span>
{% else %}
<span title="{{ 'Will send SMS'|trans }}" class="badge bg-info">
<i class="fa fa-envelope "></i>
<i class="fa fa-hourglass-end "></i>
</span>
{% endif %}
{% endif %}
</div>
</div>
</div>
</div>
<div class="item-col">
<ul class="list-content">
{% if calendar.mainUser is not empty %}
<span class="badge-user">{{ calendar.mainUser|chill_entity_render_box({'at_date': calendar.startDate}) }}</span>
{% endif %}
</ul>
</div>
</div>
</div>
{% if calendar.comment.comment is not empty
or calendar.users|length > 0
or calendar.thirdParties|length > 0
or calendar.users|length > 0 %}
<div class="item-row details separator">
<div class="item-col">
{% include '@ChillActivity/Activity/concernedGroups.html.twig' with {
'context': calendar.context == 'person' ? 'calendar_person' : 'calendar_accompanyingCourse',
'render': 'wrap-list',
'entity': calendar
} %}
</div>
</div>
{% endif %}
{% if calendar.comment.comment is not empty %}
<div class="item-row details separator">
<div class="item-col comment">
{{ calendar.comment|chill_entity_render_box( { 'limit_lines': 3, 'metadata': false } ) }}
</div>
</div>
{% endif %}
{% if calendar.location is not empty %}
<div class="item-row separator">
<div>
{% if calendar.location.address is not same as(null) and calendar.location.name is not empty %}
<i class="fa fa-map-marker"></i>{% endif %}
{% if calendar.location.name is not empty %}{{ calendar.location.name }}{% endif %}
{% if calendar.location.address is not same as(null) %}{{ calendar.location.address|chill_entity_render_box({'multiline': false, 'with_picto': (calendar.location.name is empty)}) }}{% else %}
<i class="fa fa-map-marker"></i>{% endif %}
{% if calendar.location.phonenumber1 is not empty %}<i
class="fa fa-phone"></i> {{ calendar.location.phonenumber1|chill_format_phonenumber }}{% endif %}
{% if calendar.location.phonenumber2 is not empty %}<i
class="fa fa-phone"></i> {{ calendar.location.phonenumber2|chill_format_phonenumber }}{% endif %}
</div>
</div>
{% endif %}
<div class="item-row separator column">
<div>
{{ include('@ChillCalendar/Calendar/_documents.twig.html') }}
</div>
</div>
{% if calendar.activity is not null %}
<div class="item-row separator">
<div class="item-col">
<div class="wrap-list">
<div class="wl-row">
<div class="wl-col title"><h3>{{ 'Activity'|trans }}</h3></div>
<div class="wl-col list activity-linked">
<h2 class="badge-title">
<span class="title_label"></span>
<span class="title_action">
{{ calendar.activity.type.name | localize_translatable_string }}
{% if calendar.activity.emergency %}
<span class="badge bg-danger rounded-pill fs-6 float-end">{{ 'Emergency'|trans|upper }}</span>
{% endif %}
</span>
</h2>
<ul class="record_actions">
<li class="cancel">
<span class="createdBy">
{{ 'Created by'|trans }}
<b>{{ calendar.activity.createdBy|chill_entity_render_string({'at_date': calendar.activity.createdAt}) }}</b>, {{ 'on'|trans }} {{ calendar.activity.createdAt|format_datetime('short', 'short') }}
</span>
</li>
{% if is_granted('CHILL_ACTIVITY_SEE', calendar.activity) %}
<li>
<a href="{{ chill_path_add_return_path('chill_activity_activity_show', {'id': calendar.activity.id}) }}" class="btn btn-sm btn-show" ></a>
</li>
{% endif %}
</ul>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<div class="item-row separator">
<ul class="record_actions">
{% if (calendar.isInvited(app.user)) %}
{% set invite = calendar.inviteForUser(app.user) %}
<li>
<div invite-answer data-status="{{ invite.status|e('html_attr') }}"
data-calendar-id="{{ calendar.id|e('html_attr') }}"></div>
</li>
{% endif %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_show', { 'id': calendar.id}) }}"
class="btn btn-show "></a>
</li>
</ul>
</div>
</div>
{% endif %}
{% endfor %}
{% if invitations|length < paginator.getTotalItems %}
{{ chill_pagination(paginator) }}
{% endif %}
</div>
{% endif %}

View File

@@ -0,0 +1,27 @@
{% extends "@ChillMain/layout.html.twig" %}
{% set activeRouteKey = 'chill_calendar_invitations_list' %}
{% block title %}{{ 'My invitations list' |trans }}{% endblock title %}
{% block content %}
<h1>{{ 'Invitation list' |trans }}</h1>
{% if invitations|length == 0 %}
<p class="chill-no-data-statement">
{{ "There is no invitation items."|trans }}
</p>
{% else %}
{{ include ('@ChillCalendar/Invitations/_list_item.html.twig') }}
{% endif %}
{% endblock %}
{% block js %}
{{ parent() }}
{% endblock %}
{% block css %}
{{ parent() }}
{% endblock %}

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] factory: ["@doctrine", getRepository]
arguments: arguments:
- "Chill\\CustomFieldsBundle\\Entity\\CustomFieldLongChoice\\Option" - "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\Cryptography\KeyGenerator;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Filesystem\Path; use Symfony\Component\Filesystem\Path;
@@ -147,16 +148,11 @@ class StoredObjectManager implements StoredObjectManagerInterface
public function writeContent(string $filename, string $encryptedContent): void public function writeContent(string $filename, string $encryptedContent): void
{ {
$fullPath = $this->buildPath($filename); $fullPath = $this->buildPath($filename);
$dir = Path::getDirectory($fullPath);
if (!$this->filesystem->exists($dir)) { try {
$this->filesystem->mkdir($dir); $this->filesystem->dumpFile($fullPath, $encryptedContent);
} } catch (IOExceptionInterface $exception) {
throw StoredObjectManagerException::unableToStoreDocumentOnDisk($exception);
$result = file_put_contents($fullPath, $encryptedContent);
if (false === $result) {
throw StoredObjectManagerException::unableToStoreDocumentOnDisk();
} }
} }

View File

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

View File

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

View File

@@ -72,10 +72,14 @@ class ExportConfigNormalizer
} }
$serialized['aggregators'] = $aggregatorsSerialized; $serialized['aggregators'] = $aggregatorsSerialized;
$serialized['pick_formatter'] = $formData['pick_formatter']; if ($export instanceof ExportInterface) {
$formatter = $this->exportManager->getFormatter($formData['pick_formatter']); $serialized['pick_formatter'] = $formData['pick_formatter'];
$serialized['formatter']['form'] = $formatter->normalizeFormData($formData['formatter']); $formatter = $this->exportManager->getFormatter($formData['pick_formatter']);
$serialized['formatter']['version'] = $formatter->getNormalizationVersion(); $serialized['formatter']['form'] = $formatter->normalizeFormData($formData['formatter']);
$serialized['formatter']['version'] = $formatter->getNormalizationVersion();
} elseif ($export instanceof DirectExportInterface) {
$serialized['formatter'] = ['form' => [], 'version' => 0];
}
return $serialized; return $serialized;
} }
@@ -87,7 +91,12 @@ class ExportConfigNormalizer
public function denormalizeConfig(string $exportAlias, array $serializedData, bool $replaceDisabledByDefaultData = false): array public function denormalizeConfig(string $exportAlias, array $serializedData, bool $replaceDisabledByDefaultData = false): array
{ {
$export = $this->exportManager->getExport($exportAlias); $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 = []; $filtersConfig = [];
foreach ($serializedData['filters'] as $alias => $filterData) { foreach ($serializedData['filters'] as $alias => $filterData) {
@@ -117,8 +126,8 @@ class ExportConfigNormalizer
'export' => $export->denormalizeFormData($serializedData['export']['form'], $serializedData['export']['version']), 'export' => $export->denormalizeFormData($serializedData['export']['form'], $serializedData['export']['version']),
'filters' => $filtersConfig, 'filters' => $filtersConfig,
'aggregators' => $aggregatorsConfig, 'aggregators' => $aggregatorsConfig,
'pick_formatter' => $serializedData['pick_formatter'], 'pick_formatter' => $serializedData['pick_formatter'] ?? '',
'formatter' => $formater->denormalizeFormData($serializedData['formatter']['form'], $serializedData['formatter']['version']), 'formatter' => $formatter?->denormalizeFormData($serializedData['formatter']['form'], $serializedData['formatter']['version']),
'centers' => [ 'centers' => [
'centers' => array_values(array_filter(array_map(fn (int $id) => $this->centerRepository->find($id), $serializedData['centers']['centers']), fn ($item) => null !== $item)), '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)), 'regroupments' => array_values(array_filter(array_map(fn (int $id) => $this->regroupmentRepository->find($id), $serializedData['centers']['regroupments']), fn ($item) => null !== $item)),

View File

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

View File

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

View File

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

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

View File

@@ -16,29 +16,16 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
#} #}
<!DOCTYPE html> {% extends "@ChillMain/layout.html.twig" %}
<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>
<div id="content"> {% set header_logo_only = 1 %}
{% block content %}{% endblock %}
</div> {% block title %}{{ 'Login to %installation_name%' | trans({ '%installation_name%' : installation.name } ) }}{% endblock %}
</body>
</html> {% 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 title %}{{ "New password set"|trans }}{% endblock %}
{% block content %} {% block password_content %}
<div class="col-10 centered"> <div class="col-10 centered">
<h1>{{ "New password set"|trans }}</h1> <h1>{{ "New password set"|trans }}</h1>

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,11 @@
{{ include('@ChillMain/Layout/_debug.html.twig') }} {{ include('@ChillMain/Layout/_debug.html.twig') }}
{% endif %} {% 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 %}{# {% block top_banner %}{#
To use if you want to add a banner below the header (ie the menu) To use if you want to add a banner below the header (ie the menu)

View File

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

View File

@@ -17,7 +17,6 @@ use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Household\HouseholdMember; use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Form\CreationPersonType; use Chill\PersonBundle\Form\CreationPersonType;
use Chill\PersonBundle\Form\PersonType;
use Chill\PersonBundle\Privacy\PrivacyEvent; use Chill\PersonBundle\Privacy\PrivacyEvent;
use Chill\PersonBundle\Repository\PersonRepository; use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Search\SimilarPersonMatcher; use Chill\PersonBundle\Search\SimilarPersonMatcher;
@@ -49,56 +48,6 @@ final class PersonController extends AbstractController
private readonly EntityManagerInterface $em, 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() public function getCFGroup()
{ {
$cFGroup = null; $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()
->end() // children for 'person_fields', parent = array 'person_fields' ->end() // children for 'person_fields', parent = array 'person_fields'
->end() // person_fields, parent = children of root ->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') ->arrayNode('household_fields')
->canBeDisabled() ->canBeDisabled()
->children() ->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\Household;
use Chill\PersonBundle\Entity\Household\HouseholdMember; use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Entity\Household\PersonHouseholdAddress; use Chill\PersonBundle\Entity\Household\PersonHouseholdAddress;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Person\PersonCenterCurrent; use Chill\PersonBundle\Entity\Person\PersonCenterCurrent;
use Chill\PersonBundle\Entity\Person\PersonCenterHistory; use Chill\PersonBundle\Entity\Person\PersonCenterHistory;
use Chill\PersonBundle\Entity\Person\PersonCurrentAddress; use Chill\PersonBundle\Entity\Person\PersonCurrentAddress;
@@ -271,6 +272,9 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
#[ORM\GeneratedValue(strategy: 'AUTO')] #[ORM\GeneratedValue(strategy: 'AUTO')]
private ?int $id = null; private ?int $id = null;
#[ORM\OneToMany(mappedBy: 'person', targetEntity: PersonIdentifier::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $identifiers;
/** /**
* The person's last name. * The person's last name.
*/ */
@@ -418,6 +422,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
$this->resources = new ArrayCollection(); $this->resources = new ArrayCollection();
$this->centerHistory = new ArrayCollection(); $this->centerHistory = new ArrayCollection();
$this->signatures = new ArrayCollection(); $this->signatures = new ArrayCollection();
$this->identifiers = new ArrayCollection();
} }
public function __toString(): string public function __toString(): string
@@ -498,6 +503,24 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
return $this; 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 public function removeSignature(EntityWorkflowStepSignature $signature): self
{ {
$this->signatures->removeElement($signature); $this->signatures->removeElement($signature);
@@ -1129,6 +1152,14 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
return $this->id; return $this->id;
} }
/**
* @return ReadableCollection<int, PersonIdentifier>
*/
public function getIdentifiers(): ReadableCollection
{
return $this->identifiers;
}
/** /**
* @return string * @return string
*/ */
@@ -1262,6 +1293,22 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
return $this->spokenLanguages; 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 public function getUpdatedAt(): ?\DateTimeInterface
{ {
return $this->updatedAt; 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) public function buildForm(FormBuilderInterface $builder, array $options)
{ {
$builder $builder
->add('firstName') ->add('firstName', TextType::class, ['empty_data' => ''])
->add('lastName') ->add('lastName', TextType::class, ['empty_data' => ''])
->add('birthdate', ChillDateType::class, [ ->add('birthdate', ChillDateType::class, [
'required' => false, 'required' => false,
]) ])
@@ -101,7 +101,7 @@ class PersonType extends AbstractType
if ('visible' === $this->config['memo']) { if ('visible' === $this->config['memo']) {
$builder $builder
->add('memo', ChillTextareaType::class, ['required' => false]); ->add('memo', ChillTextareaType::class, ['required' => false, 'empty_data' => '']);
} }
if ('visible' === $this->config['employment_status']) { if ('visible' === $this->config['employment_status']) {
@@ -118,6 +118,7 @@ class PersonType extends AbstractType
$builder->add('placeOfBirth', TextType::class, [ $builder->add('placeOfBirth', TextType::class, [
'required' => false, 'required' => false,
'attr' => ['style' => 'text-transform: uppercase;'], 'attr' => ['style' => 'text-transform: uppercase;'],
'empty_data' => '',
]); ]);
$builder->get('placeOfBirth')->addModelTransformer(new CallbackTransformer( $builder->get('placeOfBirth')->addModelTransformer(new CallbackTransformer(
@@ -127,7 +128,9 @@ class PersonType extends AbstractType
} }
if ('visible' === $this->config['contact_info']) { 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']) { if ('visible' === $this->config['phonenumber']) {
@@ -152,12 +155,12 @@ class PersonType extends AbstractType
'required' => false, 'required' => false,
] ]
) )
->add('acceptSMS', CheckboxType::class, [ ->add('acceptSms', CheckboxType::class, [
'required' => false, 'required' => false,
]); ]);
} }
$builder->add('otherPhoneNumbers', ChillCollectionType::class, [ $builder->add('otherPhonenumbers', ChillCollectionType::class, [
'entry_type' => PersonPhoneType::class, 'entry_type' => PersonPhoneType::class,
'button_add_label' => 'Add new phone', 'button_add_label' => 'Add new phone',
'button_remove_label' => 'Remove phone', 'button_remove_label' => 'Remove phone',
@@ -173,12 +176,12 @@ class PersonType extends AbstractType
if ('visible' === $this->config['email']) { if ('visible' === $this->config['email']) {
$builder $builder
->add('email', EmailType::class, ['required' => false]); ->add('email', EmailType::class, ['required' => false, 'empty_data' => '']);
} }
if ('visible' === $this->config['acceptEmail']) { if ('visible' === $this->config['acceptEmail']) {
$builder $builder
->add('acceptEmail', CheckboxType::class, ['required' => false]); ->add('acceptEmail', CheckboxType::class, ['required' => false, 'empty_data' => '']);
} }
if ('visible' === $this->config['country_of_birth']) { 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']) { if ($options['cFGroup']) {
$builder $builder
->add( ->add(
@@ -232,10 +239,7 @@ class PersonType extends AbstractType
} }
} }
/** public function configureOptions(OptionsResolver $resolver): void
* @param OptionsResolverInterface $resolver
*/
public function configureOptions(OptionsResolver $resolver)
{ {
$resolver->setDefaults([ $resolver->setDefaults([
'data_class' => Person::class, 'data_class' => Person::class,
@@ -251,10 +255,7 @@ class PersonType extends AbstractType
); );
} }
/** public function getBlockPrefix(): string
* @return string
*/
public function getBlockPrefix()
{ {
return 'chill_personbundle_person'; 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; font-style: italic;
} }
.created-updated {
border: 1px solid black;
padding: 10px;
}
/// Masonry blocs on AccompanyingCourse resume page /// Masonry blocs on AccompanyingCourse resume page
div#dashboards { div#dashboards {
div.mbloc { div.mbloc {

View File

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

View File

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

View File

@@ -31,7 +31,7 @@
{% if form.memo is defined %} {% if form.memo is defined %}
<fieldset> <fieldset>
<legend><h2>{{ 'Memo'|trans }}</h2></legend> <legend><h2>{{ 'Memo'|trans }}</h2></legend>
{{ form_row(form.memo, {'label' : 'Memo'} ) }} {{ form_widget(form.memo, {'label' : 'Memo'} ) }}
</fieldset> </fieldset>
{% endif %} {% endif %}
@@ -85,15 +85,17 @@
{{ form_row(form.mobilenumber, {'label': 'Mobilenumber'}) }} {{ form_row(form.mobilenumber, {'label': 'Mobilenumber'}) }}
</div> </div>
<div id="personAcceptSMS"> <div id="personAcceptSMS">
{{ form_row(form.acceptSMS, {'label' : 'Accept short text message ?'}) }} {{ form_row(form.acceptSms, {'label' : 'Accept short text message ?'}) }}
</div> </div>
{%- endif -%} {%- endif -%}
{%- if form.otherPhoneNumbers is defined -%} {%- if form.otherPhonenumbers is defined -%}
{{ form_widget(form.otherPhoneNumbers) }} {{ form_widget(form.otherPhonenumbers) }}
{{ form_errors(form.otherPhoneNumbers) }} {{ form_errors(form.otherPhonenumbers) }}
{%- endif -%} {%- endif -%}
{%- if form.contactInfo is defined -%} {%- 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 -%} {%- endif -%}
</fieldset> </fieldset>
{%- endif -%} {%- endif -%}
@@ -134,6 +136,20 @@
</fieldset> </fieldset>
{%- endif -%} {%- 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) }} {{ form_rest(form) }}
<ul class="record_actions sticky-form-buttons"> <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" %} {% extends "@ChillPerson/Person/layout.html.twig" %}
{% set activeRouteKey = 'chill_person_view' %} {% set activeRouteKey = 'chill_person_view' %}
@@ -78,6 +62,16 @@ This view should receive those arguments:
{% else %} {% else %}
<dd>{{ 'gender.not defined'|trans }}</dd> <dd>{{ 'gender.not defined'|trans }}</dd>
{% endif %} {% 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> </dl>
</figure> </figure>
</div> </div>
@@ -126,16 +120,6 @@ This view should receive those arguments:
</figure> </figure>
</div> </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>
<div class="row"> <div class="row">
@@ -241,17 +225,20 @@ This view should receive those arguments:
<span class="chill-no-data-statement">{{ 'No data given'|trans }}</span> <span class="chill-no-data-statement">{{ 'No data given'|trans }}</span>
{% endif %} {% endif %}
</dd> </dd>
<dt>{{ 'Comment on the marital status'|trans }}&nbsp;:</dt> {% if person.maritalStatusComment.comment is not empty %}
<dt>{{ 'Comment on the marital status'|trans }}&nbsp;:</dt>
<dd> <dd>
{% if person.maritalStatusComment.comment is not empty %} <blockquote class="chill-user-quote">
<blockquote class="chill-user-quote"> {{ person.maritalStatusComment.comment|chill_markdown_to_html }}
{{ person.maritalStatusComment.comment|chill_markdown_to_html }} </blockquote>
</blockquote> </dd>
{% else %} {% endif %}
<span class="chill-no-data-statement">{{ 'No data given'|trans }}</span> {% 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 %} {% endif %}
</dd> {% endfor %}
</dl> </dl>
{%- endif -%} {%- endif -%}
</figure> </figure>
@@ -341,7 +328,7 @@ This view should receive those arguments:
</div> </div>
{% endif %} {% endif %}
<div class="created-updated"> <div>
{% if person.createdBy %} {% if person.createdBy %}
<div class="createdBy"> <div class="createdBy">
{{ 'Created by'|trans}}: <b>{{ person.createdBy|chill_entity_render_box({'at_date': person.createdAt}) }}</b>,<br> {{ '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; 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 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: Chill\PersonBundle\Service\EntityInfo\AccompanyingPeriodViewEntityInfoProvider:
arguments: arguments:
$unions: !tagged_iterator chill_person.accompanying_period_info_part $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} other {Participants}
} }
number: >-
n° {id}
person: person:
from_the: depuis le from_the: depuis le
And himself: >- And himself: >-

View File

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