mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-09-25 16:14:59 +00:00
Compare commits
15 Commits
Author | SHA1 | Date | |
---|---|---|---|
230c758255 | |||
eafda987ae | |||
7db8a371fc | |||
0d0649dd31 | |||
ac12b8cdcf | |||
9c1611d052 | |||
90e3043c3d | |||
af13bf9088 | |||
4aa65d69c7 | |||
9e33aec594 | |||
f88bc7e9f0 | |||
8e78c41549
|
|||
6e36771349
|
|||
dfab223391 | |||
539752485c |
@@ -1,9 +1,10 @@
|
|||||||
## v4.2.0 - 2025-09-02
|
## v4.2.0 - 2025-09-02
|
||||||
### Feature
|
### Feature
|
||||||
* ([#64](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/64)) Add external identifier for a Person
|
* ([#64](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/64)) Add external identifier for a Person
|
||||||
|
|
||||||
**Schema Change**: Add columns or tables
|
**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
|
### Fixed
|
||||||
* ([#422](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/422)) Fixed html layout of pages for recovering password
|
* ([#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 typo in 'uncheckAll' script for centers selection
|
||||||
* Fix incorrect parameter name in event details link
|
* Fix incorrect parameter name in event details link
|
||||||
|
6
.changes/v4.2.1.md
Normal file
6
.changes/v4.2.1.md
Normal 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
10
.changes/v4.3.0.md
Normal 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
|
@@ -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
|
||||||
|
|
||||||
|
29
CHANGELOG.md
29
CHANGELOG.md
@@ -6,15 +6,34 @@ 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.0 - 2025-09-02
|
## v4.3.0 - 2025-09-08
|
||||||
### Feature
|
### Feature
|
||||||
* ([#64](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/64)) Add external identifier for a Person
|
* ([#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
|
**Schema Change**: Add columns or tables
|
||||||
### Fixed
|
### Fixed
|
||||||
* ([#422](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/422)) Fixed html layout of pages for recovering password
|
* fix date formatting in calendar range display
|
||||||
* Fix typo in 'uncheckAll' script for centers selection
|
* Change route URL to avoid clash with person duplicate controller method
|
||||||
* Fix incorrect parameter name in event details link
|
|
||||||
|
## 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
|
||||||
|
@@ -70,6 +70,8 @@
|
|||||||
<option value="00:10:00">10 minutes</option>
|
<option value="00:10:00">10 minutes</option>
|
||||||
<option value="00:15:00">15 minutes</option>
|
<option value="00:15:00">15 minutes</option>
|
||||||
<option value="00:30:00">30 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>
|
</select>
|
||||||
<label class="input-group-text" for="slotMinTime">De</label>
|
<label class="input-group-text" for="slotMinTime">De</label>
|
||||||
<select
|
<select
|
||||||
|
@@ -32,6 +32,8 @@
|
|||||||
<option value="00:10:00">10 minutes</option>
|
<option value="00:10:00">10 minutes</option>
|
||||||
<option value="00:15:00">15 minutes</option>
|
<option value="00:15:00">15 minutes</option>
|
||||||
<option value="00:30:00">30 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>
|
</select>
|
||||||
<label class="input-group-text" for="slotMinTime">De</label>
|
<label class="input-group-text" for="slotMinTime">De</label>
|
||||||
<select
|
<select
|
||||||
@@ -102,7 +104,8 @@
|
|||||||
event.title
|
event.title
|
||||||
}}</b>
|
}}</b>
|
||||||
<b v-else-if="event.extendedProps.is === 'range'"
|
<b v-else-if="event.extendedProps.is === 'range'"
|
||||||
>{{ formatDate(event.startStr) }} -
|
>{{ formatDate(event.startStr, "time") }} -
|
||||||
|
{{ formatDate(event.endStr, "time") }}:
|
||||||
{{ event.extendedProps.locationName }}</b
|
{{ event.extendedProps.locationName }}</b
|
||||||
>
|
>
|
||||||
<b v-else-if="event.extendedProps.is === 'local'">{{
|
<b v-else-if="event.extendedProps.is === 'local'">{{
|
||||||
@@ -294,9 +297,26 @@ const nextWeeks = computed((): Weeks[] =>
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const formatDate = (datetime: string) => {
|
const formatDate = (datetime: string, format: null | "time" = null) => {
|
||||||
console.log(typeof datetime);
|
const date = ISOToDate(datetime);
|
||||||
return 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>({
|
const baseOptions = ref<CalendarOptions>({
|
||||||
|
@@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
@@ -48,6 +48,7 @@ class AbsenceController extends AbstractController
|
|||||||
$user = $this->security->getUser();
|
$user = $this->security->getUser();
|
||||||
|
|
||||||
$user->setAbsenceStart(null);
|
$user->setAbsenceStart(null);
|
||||||
|
$user->setAbsenceEnd(null);
|
||||||
$em = $this->managerRegistry->getManager();
|
$em = $this->managerRegistry->getManager();
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|
||||||
|
@@ -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'] ?? [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@@ -24,6 +24,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
|
|||||||
use Symfony\Component\Serializer\Annotation as Serializer;
|
use Symfony\Component\Serializer\Annotation as Serializer;
|
||||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
|
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User.
|
* User.
|
||||||
@@ -45,6 +46,8 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
|
|||||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
|
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
private ?\DateTimeImmutable $absenceStart = null;
|
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.
|
* Array where SAML attributes's data are stored.
|
||||||
*/
|
*/
|
||||||
@@ -157,6 +160,11 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
|
|||||||
return $this->absenceStart;
|
return $this->absenceStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getAbsenceEnd(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->absenceEnd;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get attributes.
|
* Get attributes.
|
||||||
*
|
*
|
||||||
@@ -336,7 +344,13 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
|
|||||||
|
|
||||||
public function isAbsent(): bool
|
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;
|
$this->absenceStart = $absenceStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setAbsenceEnd(?\DateTimeImmutable $absenceEnd): void
|
||||||
|
{
|
||||||
|
$this->absenceEnd = $absenceEnd;
|
||||||
|
}
|
||||||
|
|
||||||
public function setAttributeByDomain(string $domain, string $key, $value): self
|
public function setAttributeByDomain(string $domain, string $key, $value): self
|
||||||
{
|
{
|
||||||
$this->attributes[$domain][$key] = $value;
|
$this->attributes[$domain][$key] = $value;
|
||||||
@@ -675,4 +694,16 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
|
|||||||
{
|
{
|
||||||
return 'fr';
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -20,7 +20,7 @@ use Chill\MainBundle\Repository\CenterRepositoryInterface;
|
|||||||
use Chill\MainBundle\Repository\RegroupmentRepositoryInterface;
|
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
|
class ExportConfigNormalizer
|
||||||
{
|
{
|
||||||
@@ -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)),
|
||||||
|
@@ -23,9 +23,14 @@ class AbsenceType extends AbstractType
|
|||||||
{
|
{
|
||||||
$builder
|
$builder
|
||||||
->add('absenceStart', ChillDateType::class, [
|
->add('absenceStart', ChillDateType::class, [
|
||||||
'required' => true,
|
'required' => false,
|
||||||
'input' => 'datetime_immutable',
|
'input' => 'datetime_immutable',
|
||||||
'label' => 'absence.Absence start',
|
'label' => 'absence.Absence start',
|
||||||
|
])
|
||||||
|
->add('absenceEnd', ChillDateType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'input' => 'datetime_immutable',
|
||||||
|
'label' => 'absence.Absence end',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -105,6 +105,11 @@ class UserType extends AbstractType
|
|||||||
'required' => false,
|
'required' => false,
|
||||||
'input' => 'datetime_immutable',
|
'input' => 'datetime_immutable',
|
||||||
'label' => 'absence.Absence start',
|
'label' => 'absence.Absence start',
|
||||||
|
])
|
||||||
|
->add('absenceEnd', ChillDateType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'input' => 'datetime_immutable',
|
||||||
|
'label' => 'absence.Absence end',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// @phpstan-ignore-next-line
|
// @phpstan-ignore-next-line
|
||||||
|
@@ -37,8 +37,13 @@ export const ISOToDate = (str: string | null): Date | null => {
|
|||||||
return 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);
|
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 => {
|
export const datetimeToISO = (date: Date): string => {
|
||||||
let cal, time, offset;
|
const cal = [
|
||||||
cal = [
|
|
||||||
date.getFullYear(),
|
date.getFullYear(),
|
||||||
(date.getMonth() + 1).toString().padStart(2, "0"),
|
(date.getMonth() + 1).toString().padStart(2, "0"),
|
||||||
date.getDate().toString().padStart(2, "0"),
|
date.getDate().toString().padStart(2, "0"),
|
||||||
].join("-");
|
].join("-");
|
||||||
|
|
||||||
time = [
|
const time = [
|
||||||
date.getHours().toString().padStart(2, "0"),
|
date.getHours().toString().padStart(2, "0"),
|
||||||
date.getMinutes().toString().padStart(2, "0"),
|
date.getMinutes().toString().padStart(2, "0"),
|
||||||
date.getSeconds().toString().padStart(2, "0"),
|
date.getSeconds().toString().padStart(2, "0"),
|
||||||
].join(":");
|
].join(":");
|
||||||
|
|
||||||
offset = [
|
const offset = [
|
||||||
date.getTimezoneOffset() <= 0 ? "+" : "-",
|
date.getTimezoneOffset() <= 0 ? "+" : "-",
|
||||||
Math.abs(Math.floor(date.getTimezoneOffset() / 60))
|
Math.abs(Math.floor(date.getTimezoneOffset() / 60))
|
||||||
.toString()
|
.toString()
|
||||||
|
@@ -8,36 +8,36 @@
|
|||||||
|
|
||||||
<div class="col-md-10">
|
<div class="col-md-10">
|
||||||
<h2>{{ 'absence.My absence'|trans }}</h2>
|
<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 %}
|
<ul class="record_actions sticky-form-buttons">
|
||||||
<div>
|
<li>
|
||||||
<p>{{ 'absence.You are listed as absent, as of'|trans }} {{ user.absenceStart|format_date('long') }}</p>
|
<a class="btn btn-delete" title="Modifier" href="{{ path('chill_main_user_absence_unset') }}">{{ 'absence.Unset absence'|trans }}</a>
|
||||||
<ul class="record_actions sticky-form-buttons">
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ path('chill_main_user_absence_unset') }}"
|
<button class="btn btn-save" type="submit">
|
||||||
class="btn btn-delete">{{ 'absence.Unset absence'|trans }}</a>
|
{{ 'Save'|trans }}
|
||||||
</li>
|
</button>
|
||||||
</ul>
|
</li>
|
||||||
</div>
|
</ul>
|
||||||
{% else %}
|
{{ form_end(form) }}
|
||||||
<div>
|
</div>
|
||||||
<p class="chill-no-data-statement">{{ 'absence.No absence listed'|trans }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ form_start(form) }}
|
|
||||||
{{ form_row(form.absenceStart) }}
|
|
||||||
|
|
||||||
<ul class="record_actions sticky-form-buttons">
|
|
||||||
<li>
|
|
||||||
<button class="btn btn-save" type="submit">
|
|
||||||
{{ 'Save'|trans }}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{{ form_end(form) }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -79,7 +79,7 @@
|
|||||||
<div class="d-flex flex-row mb-5 alert alert-warning" role="alert">
|
<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>
|
<p class="m-2">{{'absence.You are marked as being absent'|trans }}</p>
|
||||||
<span class="ms-auto">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
86
src/Bundle/ChillMainBundle/Security/RoleDumper.php
Normal file
86
src/Bundle/ChillMainBundle/Security/RoleDumper.php
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@@ -52,12 +52,8 @@ class RoleProvider
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the title for each role.
|
* 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();
|
$this->initializeRolesTitlesCache();
|
||||||
|
|
||||||
@@ -73,7 +69,7 @@ class RoleProvider
|
|||||||
/**
|
/**
|
||||||
* initialize the array for caching role and titles.
|
* initialize the array for caching role and titles.
|
||||||
*/
|
*/
|
||||||
private function initializeRolesTitlesCache()
|
private function initializeRolesTitlesCache(): void
|
||||||
{
|
{
|
||||||
// break if already initialized
|
// break if already initialized
|
||||||
if (null !== $this->rolesTitlesCache) {
|
if (null !== $this->rolesTitlesCache) {
|
||||||
|
@@ -39,6 +39,8 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
|
|||||||
'label' => '',
|
'label' => '',
|
||||||
'email' => '',
|
'email' => '',
|
||||||
'isAbsent' => false,
|
'isAbsent' => false,
|
||||||
|
'absenceStart' => null,
|
||||||
|
'absenceEnd' => null,
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(private readonly UserRender $userRender, private readonly ClockInterface $clock) {}
|
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']
|
['docgen:expects' => PhoneNumber::class, 'groups' => 'docgen:read']
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$absenceDatesContext = array_merge(
|
||||||
|
$context,
|
||||||
|
['docgen:expects' => \DateTimeImmutable::class, 'groups' => 'docgen:read']
|
||||||
|
);
|
||||||
|
|
||||||
if (null === $object && 'docgen' === $format) {
|
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)];
|
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_center' => $this->normalizer->normalize($object->getMainCenter(), $format, $centerContext),
|
||||||
'main_scope' => $this->normalizer->normalize($object->getMainScope($at), $format, $scopeContext),
|
'main_scope' => $this->normalizer->normalize($object->getMainScope($at), $format, $scopeContext),
|
||||||
'isAbsent' => $object->isAbsent(),
|
'isAbsent' => $object->isAbsent(),
|
||||||
|
'absenceStart' => $this->normalizer->normalize($object->getAbsenceStart(), $format, $absenceDatesContext),
|
||||||
|
'absenceEnd' => $this->normalizer->normalize($object->getAbsenceEnd(), $format, $absenceDatesContext),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ('docgen' === $format) {
|
if ('docgen' === $format) {
|
||||||
|
@@ -67,4 +67,36 @@ class UserTest extends TestCase
|
|||||||
->first()->getEndDate()
|
->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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
98
src/Bundle/ChillMainBundle/Tests/Security/RoleDumperTest.php
Normal file
98
src/Bundle/ChillMainBundle/Tests/Security/RoleDumperTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@@ -101,6 +101,8 @@ final class UserNormalizerTest extends TestCase
|
|||||||
'text_without_absent' => 'SomeUser',
|
'text_without_absent' => 'SomeUser',
|
||||||
'isAbsent' => false,
|
'isAbsent' => false,
|
||||||
'main_center' => ['context' => Center::class],
|
'main_center' => ['context' => Center::class],
|
||||||
|
'absenceStart' => ['context' => \DateTimeImmutable::class],
|
||||||
|
'absenceEnd' => ['context' => \DateTimeImmutable::class],
|
||||||
]];
|
]];
|
||||||
|
|
||||||
yield [$userNoPhone, 'docgen', ['docgen:expects' => User::class],
|
yield [$userNoPhone, 'docgen', ['docgen:expects' => User::class],
|
||||||
@@ -120,6 +122,8 @@ final class UserNormalizerTest extends TestCase
|
|||||||
'text_without_absent' => 'AnotherUser',
|
'text_without_absent' => 'AnotherUser',
|
||||||
'isAbsent' => false,
|
'isAbsent' => false,
|
||||||
'main_center' => ['context' => Center::class],
|
'main_center' => ['context' => Center::class],
|
||||||
|
'absenceStart' => ['context' => \DateTimeImmutable::class],
|
||||||
|
'absenceEnd' => ['context' => \DateTimeImmutable::class],
|
||||||
]];
|
]];
|
||||||
|
|
||||||
yield [null, 'docgen', ['docgen:expects' => User::class], [
|
yield [null, 'docgen', ['docgen:expects' => User::class], [
|
||||||
@@ -138,6 +142,8 @@ final class UserNormalizerTest extends TestCase
|
|||||||
'text_without_absent' => '',
|
'text_without_absent' => '',
|
||||||
'isAbsent' => false,
|
'isAbsent' => false,
|
||||||
'main_center' => ['context' => Center::class],
|
'main_center' => ['context' => Center::class],
|
||||||
|
'absenceStart' => null,
|
||||||
|
'absenceEnd' => null,
|
||||||
]];
|
]];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -80,3 +80,7 @@ services:
|
|||||||
Chill\MainBundle\Command\SynchronizeEntityInfoViewsCommand:
|
Chill\MainBundle\Command\SynchronizeEntityInfoViewsCommand:
|
||||||
tags:
|
tags:
|
||||||
- {name: console.command}
|
- {name: console.command}
|
||||||
|
|
||||||
|
Chill\MainBundle\Command\DumpListPermissionsCommand:
|
||||||
|
autoconfigure: true
|
||||||
|
autowire: true
|
||||||
|
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
@@ -136,3 +136,7 @@ filter_order:
|
|||||||
Search: Chercher dans la liste
|
Search: Chercher dans la liste
|
||||||
By date: Filtrer par date
|
By date: Filtrer par date
|
||||||
search_box: Filtrer par contenu
|
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}
|
||||||
|
|
||||||
|
@@ -841,12 +841,12 @@ absence:
|
|||||||
# single letter for absence
|
# single letter for absence
|
||||||
A: A
|
A: A
|
||||||
My absence: Mon absence
|
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
|
Set absence date: Indiquer une date d'absence
|
||||||
Absence start: Absent à partir du
|
Absence start: Absent à partir du
|
||||||
|
Absence end: Jusqu'au
|
||||||
Absent: Absent
|
Absent: Absent
|
||||||
You are marked as being absent: Vous êtes indiqué 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.
|
No absence listed: Aucune absence indiquée.
|
||||||
Is absent: Absent?
|
Is absent: Absent?
|
||||||
|
|
||||||
|
@@ -40,3 +40,7 @@ workflow:
|
|||||||
|
|
||||||
rolling_date:
|
rolling_date:
|
||||||
When fixed date is selected, you must provide a date: Indiquez la date fixe choisie
|
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."
|
||||||
|
|
||||||
|
@@ -78,7 +78,7 @@ class AccompanyingPeriodWorkDuplicateController extends AbstractController
|
|||||||
* @ParamConverter("acpw1", options={"id": "acpw1_id"})
|
* @ParamConverter("acpw1", options={"id": "acpw1_id"})
|
||||||
* @ParamConverter("acpw2", options={"id": "acpw2_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)
|
public function confirmAction(AccompanyingPeriodWork $acpw1, AccompanyingPeriodWork $acpw2, Request $request)
|
||||||
{
|
{
|
||||||
$accompanyingPeriod = $acpw1->getAccompanyingPeriod();
|
$accompanyingPeriod = $acpw1->getAccompanyingPeriod();
|
||||||
|
Reference in New Issue
Block a user