mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-11-01 01:38:23 +00:00
Compare commits
22 Commits
420-locali
...
453-bug-cs
| Author | SHA1 | Date | |
|---|---|---|---|
| 36dfce6613 | |||
| 9c2abb2dfa | |||
| 94744b9542 | |||
| f42bb498e4 | |||
| 01889ac671 | |||
| 62e5842311 | |||
|
8ad6f397a8
|
|||
| d713704633 | |||
| b1fa9242a0 | |||
| 6ac554f93a | |||
| 372d8e5825 | |||
| 10f05e5559 | |||
| ddb2a65419 | |||
| 8d40a8089f | |||
| e1bf4a24d2 | |||
| 208a378185 | |||
| 9089c8959b | |||
|
1b9b581c31
|
|||
| aa1abe4c88 | |||
| d82c9cc9a7 | |||
| a7e3b1c5d2 | |||
| 84cf11933d |
7
.changes/unreleased/DX-20251027-150053.yaml
Normal file
7
.changes/unreleased/DX-20251027-150053.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
kind: DX
|
||||
body: |
|
||||
Send notifications log to dedicated channel, if it exists
|
||||
time: 2025-10-27T15:00:53.309372316+01:00
|
||||
custom:
|
||||
Issue: ""
|
||||
SchemaChange: No schema change
|
||||
@@ -1,6 +0,0 @@
|
||||
kind: Fixed
|
||||
body: Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted
|
||||
time: 2025-10-03T22:40:44.685474863+02:00
|
||||
custom:
|
||||
Issue: ""
|
||||
SchemaChange: No schema change
|
||||
@@ -1,6 +0,0 @@
|
||||
kind: Fixed
|
||||
body: 'Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists'
|
||||
time: 2025-10-06T12:13:15.45905994+02:00
|
||||
custom:
|
||||
Issue: "434"
|
||||
SchemaChange: No schema change
|
||||
6
.changes/unreleased/Fixed-20251029-143836.yaml
Normal file
6
.changes/unreleased/Fixed-20251029-143836.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
kind: Fixed
|
||||
body: 'Fix: export actions and their results in csv even when action does not have any goals attached to it.'
|
||||
time: 2025-10-29T14:38:36.195220844+01:00
|
||||
custom:
|
||||
Issue: "453"
|
||||
SchemaChange: No schema change
|
||||
14
.changes/v4.6.0.md
Normal file
14
.changes/v4.6.0.md
Normal file
@@ -0,0 +1,14 @@
|
||||
## v4.6.0 - 2025-10-15
|
||||
### Feature
|
||||
* ([#423](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/423)) Create environment banner that can be activated and configured depending on the image deployed
|
||||
* ([#394](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/394)) Only show active workflow on the page "my tracked workflow"
|
||||
### Fixed
|
||||
* Fix loading of classLists in SocialIssuesAcc.vue, ensure elements are present
|
||||
* Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted
|
||||
* ([#434](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/434)) Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists
|
||||
* Fix loading of social issues and social actions within vue component
|
||||
* ([#446](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/446)) Add unique condition on stored object filename, with cleaning step on existing duplicate filenames
|
||||
|
||||
**Schema Change**: Drop or rename table or columns, or enforce new constraint that must be manually fixed
|
||||
* [workflow] take permissions into account to delete the workflow attachment
|
||||
* ([#448](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/448)) Fix the execution of daily cronjob notification, when the previous last execution storage was invalid
|
||||
3
.changes/v4.6.1.md
Normal file
3
.changes/v4.6.1.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## v4.6.1 - 2025-10-27
|
||||
### Fixed
|
||||
* Fix export case where no 'reason' is picked within the PersonHavingActivityBetweenDateFilter.php
|
||||
@@ -240,9 +240,6 @@ The tests are run from the project's root (not from the bundle's root).
|
||||
# Run all tests
|
||||
vendor/bin/phpunit
|
||||
|
||||
# Run tests for a specific bundle
|
||||
vendor/bin/phpunit --testsuite NameBundle
|
||||
|
||||
# Run a specific test file
|
||||
vendor/bin/phpunit path/to/TestFile.php
|
||||
|
||||
@@ -250,6 +247,9 @@ vendor/bin/phpunit path/to/TestFile.php
|
||||
vendor/bin/phpunit --filter methodName path/to/TestFile.php
|
||||
```
|
||||
|
||||
When writing tests, only test specific files. Do not run all tests or the full
|
||||
test suite.
|
||||
|
||||
#### Test Structure
|
||||
|
||||
Tests are organized by bundle and follow the same structure as the bundle itself:
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -6,6 +6,25 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
|
||||
and is generated by [Changie](https://github.com/miniscruff/changie).
|
||||
|
||||
|
||||
## v4.6.1 - 2025-10-27
|
||||
### Fixed
|
||||
* Fix export case where no 'reason' is picked within the PersonHavingActivityBetweenDateFilter.php
|
||||
|
||||
## v4.6.0 - 2025-10-15
|
||||
### Feature
|
||||
* ([#423](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/423)) Create environment banner that can be activated and configured depending on the image deployed
|
||||
* ([#394](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/394)) Only show active workflow on the page "my tracked workflow"
|
||||
### Fixed
|
||||
* Fix loading of classLists in SocialIssuesAcc.vue, ensure elements are present
|
||||
* Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted
|
||||
* ([#434](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/434)) Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists
|
||||
* Fix loading of social issues and social actions within vue component
|
||||
* ([#446](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/446)) Add unique condition on stored object filename, with cleaning step on existing duplicate filenames
|
||||
|
||||
**Schema Change**: Drop or rename table or columns, or enforce new constraint that must be manually fixed
|
||||
* [workflow] take permissions into account to delete the workflow attachment
|
||||
* ([#448](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/448)) Fix the execution of daily cronjob notification, when the previous last execution storage was invalid
|
||||
|
||||
## v4.5.1 - 2025-10-03
|
||||
### Fixed
|
||||
* Add missing javascript dependency
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
chill_main:
|
||||
available_languages: [ '%env(resolve:LOCALE)%', 'en' ]
|
||||
available_countries: ['BE', 'FR']
|
||||
top_banner:
|
||||
visible: false
|
||||
text:
|
||||
fr: 'Vous travaillez actuellement avec la version de PRÉ-PRODUCTION.'
|
||||
nl: 'Je werkt momenteel in de PRE-PRODUCTIE versie'
|
||||
color: '#353535'
|
||||
background_color: '#d8bb48'
|
||||
notifications:
|
||||
from_email: '%env(resolve:NOTIFICATION_FROM_EMAIL)%'
|
||||
from_name: '%env(resolve:NOTIFICATION_FROM_NAME)%'
|
||||
|
||||
@@ -90,7 +90,9 @@ class ActivityReasonFilter implements ExportElementValidatedInterface, FilterInt
|
||||
|
||||
public function getFormDefaultData(): array
|
||||
{
|
||||
return [];
|
||||
return [
|
||||
'reasons' => [],
|
||||
];
|
||||
}
|
||||
|
||||
public function describeAction($data, ExportGenerationContext $context): string|\Symfony\Contracts\Translation\TranslatableInterface|array
|
||||
|
||||
@@ -42,6 +42,8 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
|
||||
|
||||
public function alterQuery(QueryBuilder $qb, $data, ExportGenerationContext $exportGenerationContext): void
|
||||
{
|
||||
error_log('alterQuery called with data: '.json_encode(array_keys($data)));
|
||||
|
||||
// create a subquery for activity
|
||||
$sqb = $qb->getEntityManager()->createQueryBuilder();
|
||||
$sqb->select('1')
|
||||
@@ -59,7 +61,6 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
|
||||
if (\in_array('activity', $qb->getAllAliases(), true)) {
|
||||
$sqb->andWhere('activity_person_having_activity.id = activity.id');
|
||||
}
|
||||
|
||||
if (isset($data['reasons']) && [] !== $data['reasons']) {
|
||||
// add clause activity reason
|
||||
$sqb->join('activity_person_having_activity.reasons', 'reasons_person_having_activity');
|
||||
@@ -124,12 +125,38 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
|
||||
|
||||
public function normalizeFormData(array $formData): array
|
||||
{
|
||||
return ['date_from_rolling' => $formData['date_from_rolling']->normalize(), 'date_to_rolling' => $formData['date_to_rolling']->normalize()];
|
||||
$normalized = [
|
||||
'date_from_rolling' => $formData['date_from_rolling']->normalize(),
|
||||
'date_to_rolling' => $formData['date_to_rolling']->normalize(),
|
||||
'reasons' => [],
|
||||
];
|
||||
|
||||
if (isset($formData['reasons']) && [] !== $formData['reasons']) {
|
||||
$normalized['reasons'] = array_map(
|
||||
fn (ActivityReason $reason) => $reason->getId(),
|
||||
$formData['reasons']
|
||||
);
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
public function denormalizeFormData(array $formData, int $fromVersion): array
|
||||
{
|
||||
return ['date_from_rolling' => RollingDate::fromNormalized($formData['date_from_rolling']), 'date_to_rolling' => RollingDate::fromNormalized($formData['date_to_rolling'])];
|
||||
$denormalized = [
|
||||
'date_from_rolling' => RollingDate::fromNormalized($formData['date_from_rolling']),
|
||||
'date_to_rolling' => RollingDate::fromNormalized($formData['date_to_rolling']),
|
||||
'reasons' => [],
|
||||
];
|
||||
|
||||
if (isset($formData['reasons']) && [] !== $formData['reasons']) {
|
||||
$denormalized['reasons'] = array_map(
|
||||
fn ($id) => $this->activityReasonRepository->find($id),
|
||||
$formData['reasons']
|
||||
);
|
||||
}
|
||||
|
||||
return $denormalized;
|
||||
}
|
||||
|
||||
public function getFormDefaultData(): array
|
||||
@@ -143,10 +170,12 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
|
||||
|
||||
public function describeAction($data, ExportGenerationContext $context): array
|
||||
{
|
||||
$reasons = $data['reasons'] ?? [];
|
||||
|
||||
return [
|
||||
[] === $data['reasons'] ?
|
||||
'export.filter.person_between_dates.describe_action_with_no_subject'
|
||||
: 'export.filter.person_between_dates.describe_action_with_subject',
|
||||
[] === $reasons ?
|
||||
'export.filter.activity.describe_action_with_no_subject'
|
||||
: 'export.filter.activity.describe_action_with_subject',
|
||||
[
|
||||
'date_from' => $this->rollingDateConverter->convert($data['date_from_rolling']),
|
||||
'date_to' => $this->rollingDateConverter->convert($data['date_to_rolling']),
|
||||
@@ -154,7 +183,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
|
||||
', ',
|
||||
array_map(
|
||||
fn (ActivityReason $r): string => '"'.$this->translatableStringHelper->localize($r->getName()).'"',
|
||||
$data['reasons']
|
||||
$reasons
|
||||
)
|
||||
),
|
||||
],
|
||||
@@ -168,6 +197,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
|
||||
|
||||
public function validateForm($data, ExecutionContextInterface $context): void
|
||||
{
|
||||
error_log('validateForm called with data: '.json_encode(array_keys($data)));
|
||||
if ($this->rollingDateConverter->convert($data['date_from_rolling'])
|
||||
>= $this->rollingDateConverter->convert($data['date_to_rolling'])) {
|
||||
$context->buildViolation('export.filter.activity.person_between_dates.date mismatch')
|
||||
|
||||
@@ -136,8 +136,14 @@ export default {
|
||||
issueIsLoading: false,
|
||||
actionIsLoading: false,
|
||||
actionAreLoaded: false,
|
||||
socialIssuesClassList: `col-form-label ${document.querySelector("input#chill_activitybundle_activity_socialIssues").getAttribute("required") ? "required" : ""}`,
|
||||
socialActionsClassList: `col-form-label ${document.querySelector("input#chill_activitybundle_activity_socialActions").getAttribute("required") ? "required" : ""}`,
|
||||
socialIssuesClassList: {
|
||||
"col-form-label": true,
|
||||
required: false,
|
||||
},
|
||||
socialActionsClassList: {
|
||||
"col-form-label": true,
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -158,6 +164,21 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
/* Load classNames after element is present */
|
||||
const socialActionsEl = document.querySelector(
|
||||
"input#chill_activitybundle_activity_socialActions",
|
||||
);
|
||||
if (socialActionsEl && socialActionsEl.hasAttribute("required")) {
|
||||
this.socialActionsClassList.required = true;
|
||||
}
|
||||
|
||||
const socialIssuesEl = document.querySelector(
|
||||
"input#chill_activitybundle_activity_socialIssues",
|
||||
);
|
||||
if (socialIssuesEl && socialIssuesEl.hasAttribute("required")) {
|
||||
this.socialIssuesClassList.required = true;
|
||||
}
|
||||
|
||||
/* Load other issues in multiselect */
|
||||
this.issueIsLoading = true;
|
||||
this.actionAreLoaded = false;
|
||||
|
||||
@@ -23,10 +23,14 @@ use Random\RandomException;
|
||||
* Store each version of StoredObject's.
|
||||
*
|
||||
* A version should not be created manually: use the method @see{StoredObject::registerVersion} instead.
|
||||
*
|
||||
* Each filename must be unique within the same StoredObject. We add a condition on id to apply this condition only for
|
||||
* newly created versions when this new index is applied.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table('chill_doc.stored_object_version')]
|
||||
#[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_object', columns: ['stored_object_id', 'version'])]
|
||||
#[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_filename', columns: ['filename'], options: ['where' => '(id > 0)'])]
|
||||
class StoredObjectVersion implements TrackCreationInterface
|
||||
{
|
||||
use TrackCreationTrait;
|
||||
|
||||
@@ -36,6 +36,18 @@ export interface GenericDocForAccompanyingPeriod extends GenericDoc {
|
||||
context: "accompanying-period";
|
||||
}
|
||||
|
||||
export function isGenericDocForAccompanyingPeriod(
|
||||
doc: GenericDoc,
|
||||
): doc is GenericDocForAccompanyingPeriod {
|
||||
return doc.context === "accompanying-period";
|
||||
}
|
||||
|
||||
export function isGenericDocWithStoredObject(
|
||||
doc: GenericDoc,
|
||||
): doc is GenericDoc & { storedObject: StoredObject } {
|
||||
return doc.storedObject !== null;
|
||||
}
|
||||
|
||||
interface BaseMetadataWithHtml extends BaseMetadata {
|
||||
html: string;
|
||||
}
|
||||
@@ -44,28 +56,33 @@ export interface GenericDocForAccompanyingCourseDocument
|
||||
extends GenericDocForAccompanyingPeriod {
|
||||
key: "accompanying_course_document";
|
||||
metadata: BaseMetadataWithHtml;
|
||||
storedObject: StoredObject;
|
||||
}
|
||||
|
||||
export interface GenericDocForAccompanyingCourseActivityDocument
|
||||
extends GenericDocForAccompanyingPeriod {
|
||||
key: "accompanying_course_activity_document";
|
||||
metadata: BaseMetadataWithHtml;
|
||||
storedObject: StoredObject;
|
||||
}
|
||||
|
||||
export interface GenericDocForAccompanyingCourseCalendarDocument
|
||||
extends GenericDocForAccompanyingPeriod {
|
||||
key: "accompanying_course_calendar_document";
|
||||
metadata: BaseMetadataWithHtml;
|
||||
storedObject: StoredObject;
|
||||
}
|
||||
|
||||
export interface GenericDocForAccompanyingCoursePersonDocument
|
||||
extends GenericDocForAccompanyingPeriod {
|
||||
key: "person_document";
|
||||
metadata: BaseMetadataWithHtml;
|
||||
storedObject: StoredObject;
|
||||
}
|
||||
|
||||
export interface GenericDocForAccompanyingCourseWorkEvaluationDocument
|
||||
extends GenericDocForAccompanyingPeriod {
|
||||
key: "accompanying_period_work_evaluation_document";
|
||||
metadata: BaseMetadataWithHtml;
|
||||
storedObject: StoredObject;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?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\DocStore;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20251013094414 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'DocStore: Enforce filename uniqueness on chill_doc.stored_object_version; clean duplicates and add partial unique index on filename (for new rows only).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// 1) Clean duplicates: for each (stored_object_id, filename, key, iv), keep only the last inserted row
|
||||
// and delete all others. Use ROW_NUMBER over id DESC to define the last one.
|
||||
$this->addSql(<<<'SQL'
|
||||
WITH ranked AS (
|
||||
SELECT id,
|
||||
rank() OVER (
|
||||
PARTITION BY stored_object_id, filename, "key"::jsonb, iv::jsonb
|
||||
ORDER BY id DESC
|
||||
) AS rn
|
||||
FROM chill_doc.stored_object_version
|
||||
)
|
||||
DELETE FROM chill_doc.stored_object_version sov
|
||||
USING ranked r
|
||||
WHERE sov.id = r.id
|
||||
AND r.rn > 1
|
||||
SQL);
|
||||
|
||||
// 2) Create a partial unique index on filename that applies only to subsequently inserted rows.
|
||||
// Per user's instruction, compute the cutoff using the stored_object_id sequence value.
|
||||
$nextVal = (int) $this->connection->fetchOne("SELECT nextval('chill_doc.stored_object_version_id_seq')");
|
||||
|
||||
// Safety: if somehow sequence is not available, fallback to current max id from the table
|
||||
if ($nextVal <= 0) {
|
||||
$nextVal = (int) $this->connection->fetchOne('SELECT COALESCE(MAX(id), 0) FROM chill_doc.stored_object_version');
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'CREATE UNIQUE INDEX chill_doc_stored_object_version_unique_by_filename ON chill_doc.stored_object_version (filename) WHERE id > %d',
|
||||
$nextVal
|
||||
));
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Drop the partial unique index; data cleanup is irreversible.
|
||||
$this->addSql('DROP INDEX IF EXISTS chill_doc_stored_object_version_unique_by_filename');
|
||||
}
|
||||
}
|
||||
@@ -334,7 +334,7 @@ class ChillImportUsersCommand extends Command
|
||||
|
||||
protected function loadUsers()
|
||||
{
|
||||
$reader = Reader::createFromPath($this->tempInput->getArgument('csvfile'));
|
||||
$reader = Reader::from($this->tempInput->getArgument('csvfile'));
|
||||
$reader->setHeaderOffset(0);
|
||||
|
||||
foreach ($reader->getRecords() as $line => $r) {
|
||||
@@ -362,7 +362,7 @@ class ChillImportUsersCommand extends Command
|
||||
|
||||
protected function prepareGroupingCenters()
|
||||
{
|
||||
$reader = Reader::createFromPath($this->tempInput->getOption('grouping-centers'));
|
||||
$reader = Reader::from($this->tempInput->getOption('grouping-centers'));
|
||||
$reader->setHeaderOffset(0);
|
||||
|
||||
foreach ($reader->getRecords() as $r) {
|
||||
@@ -378,7 +378,7 @@ class ChillImportUsersCommand extends Command
|
||||
|
||||
protected function prepareWriter()
|
||||
{
|
||||
$this->output = $output = Writer::createFromPath($this->tempInput
|
||||
$this->output = $output = Writer::from($this->tempInput
|
||||
->getOption('csv-dump'), 'a+');
|
||||
|
||||
$output->insertOne([
|
||||
|
||||
@@ -119,7 +119,7 @@ class ChillUserSendRenewPasswordCodeCommand extends Command
|
||||
protected function getReader()
|
||||
{
|
||||
try {
|
||||
$reader = Reader::createFromPath($this->input->getArgument('csvfile'));
|
||||
$reader = Reader::from($this->input->getArgument('csvfile'));
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('The csv file could not be read', [
|
||||
'path' => $this->input->getArgument('csvfile'),
|
||||
|
||||
@@ -43,7 +43,7 @@ final readonly class UserExportController
|
||||
|
||||
$users = $this->userRepository->findAllAsArray($request->getLocale());
|
||||
|
||||
$csv = Writer::createFromPath('php://temp', 'r+');
|
||||
$csv = Writer::from('php://temp', 'r+');
|
||||
$csv->insertOne(
|
||||
array_map(
|
||||
fn (string $e) => $this->translator->trans('admin.users.export.'.$e),
|
||||
@@ -104,7 +104,7 @@ final readonly class UserExportController
|
||||
|
||||
$userPermissions = $this->userRepository->findAllUserACLAsArray();
|
||||
|
||||
$csv = Writer::createFromPath('php://temp', 'r+');
|
||||
$csv = Writer::from('php://temp', 'r+');
|
||||
$csv->insertOne(
|
||||
array_map(
|
||||
fn (string $e) => $this->translator->trans('admin.users.export.'.$e),
|
||||
|
||||
@@ -264,11 +264,12 @@ class WorkflowController extends AbstractController
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
||||
|
||||
$total = $this->entityWorkflowRepository->countBySubscriber($this->security->getUser());
|
||||
$total = $this->entityWorkflowRepository->countBySubscriber($this->security->getUser(), false);
|
||||
$paginator = $this->paginatorFactory->create($total);
|
||||
|
||||
$workflows = $this->entityWorkflowRepository->findBySubscriber(
|
||||
$this->security->getUser(),
|
||||
false,
|
||||
['createdAt' => 'DESC'],
|
||||
$paginator->getItemsPerPage(),
|
||||
$paginator->getCurrentPageFirstItemNumber()
|
||||
|
||||
@@ -205,6 +205,11 @@ class ChillMainExtension extends Extension implements
|
||||
[]
|
||||
);
|
||||
|
||||
$container->setParameter(
|
||||
'chill_main.top_banner',
|
||||
$config['top_banner'] ?? []
|
||||
);
|
||||
|
||||
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
|
||||
$loader->load('services.yaml');
|
||||
$loader->load('services/doctrine.yaml');
|
||||
@@ -250,6 +255,7 @@ class ChillMainExtension extends Extension implements
|
||||
'name' => $config['installation_name'], ],
|
||||
'available_languages' => $config['available_languages'],
|
||||
'add_address' => $config['add_address'],
|
||||
'chill_main_config' => $config,
|
||||
],
|
||||
'form_themes' => ['@ChillMain/Form/fields.html.twig'],
|
||||
];
|
||||
|
||||
@@ -168,6 +168,20 @@ class Configuration implements ConfigurationInterface
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
->arrayNode('top_banner')
|
||||
->canBeUnset()
|
||||
->children()
|
||||
->booleanNode('visible')
|
||||
->defaultFalse()
|
||||
->end()
|
||||
->arrayNode('text')
|
||||
->useAttributeAsKey('lang')
|
||||
->scalarPrototype()->end()
|
||||
->end() // end of text
|
||||
->scalarNode('color')->defaultNull()->end()
|
||||
->scalarNode('background_color')->defaultNull()->end()
|
||||
->end() // end of top_banner children
|
||||
->end() // end of top_banner
|
||||
->arrayNode('widgets')
|
||||
->canBeEnabled()
|
||||
->canBeUnset()
|
||||
|
||||
@@ -53,11 +53,16 @@ readonly class DailyNotificationDigestCronjob implements CronJobInterface
|
||||
public function run(array $lastExecutionData): ?array
|
||||
{
|
||||
$now = $this->clock->now();
|
||||
|
||||
if (isset($lastExecutionData['last_execution'])) {
|
||||
$lastExecution = \DateTimeImmutable::createFromFormat(
|
||||
\DateTimeImmutable::ATOM,
|
||||
$lastExecutionData['last_execution']
|
||||
);
|
||||
|
||||
if (false === $lastExecution) {
|
||||
$lastExecution = $now->sub(new \DateInterval('P1D'));
|
||||
}
|
||||
} else {
|
||||
$lastExecution = $now->sub(new \DateInterval('P1D'));
|
||||
}
|
||||
@@ -96,7 +101,7 @@ readonly class DailyNotificationDigestCronjob implements CronJobInterface
|
||||
]);
|
||||
|
||||
return [
|
||||
'last_execution' => $now->format('Y-m-d-H:i:s.u e'),
|
||||
'last_execution' => $now->format(\DateTimeInterface::ATOM),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +57,15 @@ class EntityWorkflowRepository implements ObjectRepository
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function countBySubscriber(User $user): int
|
||||
/**
|
||||
* @param bool|null $isFinal true to get only the entityWorkflow which is finalized, false to get the workflows that are not finalized, and null to ignore
|
||||
*
|
||||
* @throws \Doctrine\ORM\NoResultException
|
||||
* @throws \Doctrine\ORM\NonUniqueResultException
|
||||
*/
|
||||
public function countBySubscriber(User $user, ?bool $isFinal = null): int
|
||||
{
|
||||
$qb = $this->buildQueryBySubscriber($user)->select('count(ew)');
|
||||
$qb = $this->buildQueryBySubscriber($user, $isFinal)->select('count(ew)');
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
@@ -182,9 +188,14 @@ class EntityWorkflowRepository implements ObjectRepository
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function findBySubscriber(User $user, ?array $orderBy = null, $limit = null, $offset = null): array
|
||||
/**
|
||||
* @param bool|null $isFinal true to get only the entityWorkflow which is finalized, false to get the workflows that are not finalized, and null to ignore
|
||||
* @param mixed|null $limit
|
||||
* @param mixed|null $offset
|
||||
*/
|
||||
public function findBySubscriber(User $user, ?bool $isFinal = null, ?array $orderBy = null, $limit = null, $offset = null): array
|
||||
{
|
||||
$qb = $this->buildQueryBySubscriber($user)->select('ew');
|
||||
$qb = $this->buildQueryBySubscriber($user, $isFinal)->select('ew');
|
||||
|
||||
foreach ($orderBy as $key => $sort) {
|
||||
$qb->addOrderBy('ew.'.$key, $sort);
|
||||
@@ -312,7 +323,7 @@ class EntityWorkflowRepository implements ObjectRepository
|
||||
return $qb;
|
||||
}
|
||||
|
||||
private function buildQueryBySubscriber(User $user): QueryBuilder
|
||||
private function buildQueryBySubscriber(User $user, ?bool $isFinal): QueryBuilder
|
||||
{
|
||||
$qb = $this->repository->createQueryBuilder('ew');
|
||||
|
||||
@@ -325,6 +336,14 @@ class EntityWorkflowRepository implements ObjectRepository
|
||||
|
||||
$qb->setParameter('user', $user);
|
||||
|
||||
if (null !== $isFinal) {
|
||||
if ($isFinal) {
|
||||
$qb->andWhere(sprintf('EXISTS (SELECT 1 FROM %s step WHERE step.isFinal = true AND ew = step.entityWorkflow)', EntityWorkflowStep::class));
|
||||
} else {
|
||||
$qb->andWhere(sprintf('NOT EXISTS (SELECT 1 FROM %s step WHERE step.isFinal = true AND ew = step.entityWorkflow)', EntityWorkflowStep::class));
|
||||
}
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
|
||||
import {
|
||||
GenericDoc,
|
||||
isGenericDocWithStoredObject,
|
||||
} from "ChillDocStoreAssets/types/generic_doc";
|
||||
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
|
||||
import { Person } from "../../../ChillPersonBundle/Resources/public/types";
|
||||
|
||||
@@ -203,6 +206,25 @@ export interface WorkflowAttachment {
|
||||
genericDoc: null | GenericDoc;
|
||||
}
|
||||
|
||||
export type AttachmentWithDocAndStored = WorkflowAttachment & {
|
||||
genericDoc: GenericDoc & { storedObject: StoredObject };
|
||||
};
|
||||
|
||||
export function isAttachmentWithDocAndStored(
|
||||
a: WorkflowAttachment,
|
||||
): a is AttachmentWithDocAndStored {
|
||||
return (
|
||||
isWorkflowAttachmentWithGenericDoc(a) &&
|
||||
isGenericDocWithStoredObject(a.genericDoc)
|
||||
);
|
||||
}
|
||||
|
||||
export function isWorkflowAttachmentWithGenericDoc(
|
||||
attachment: WorkflowAttachment,
|
||||
): attachment is WorkflowAttachment & { genericDoc: GenericDoc } {
|
||||
return attachment.genericDoc !== null;
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
name: string;
|
||||
text: string;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/gener
|
||||
import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue";
|
||||
import { GenericDoc } from "ChillDocStoreAssets/types";
|
||||
import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api";
|
||||
import { trans, WORKFLOW_ATTACHMENTS_ADD_AN_ATTACHMENT } from "translator";
|
||||
|
||||
interface AppConfig {
|
||||
workflowId: number;
|
||||
@@ -83,7 +84,7 @@ const canEditAttachement = computed<boolean>(() => {
|
||||
<ul v-if="canEditAttachement" class="record_actions">
|
||||
<li>
|
||||
<button type="button" class="btn btn-create" @click="openModal">
|
||||
Ajouter une pièce jointe
|
||||
{{ trans(WORKFLOW_ATTACHMENTS_ADD_AN_ATTACHMENT) }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types";
|
||||
import {
|
||||
AttachmentWithDocAndStored,
|
||||
EntityWorkflow,
|
||||
isAttachmentWithDocAndStored,
|
||||
WorkflowAttachment,
|
||||
} from "ChillMainAssets/types";
|
||||
import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue";
|
||||
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
|
||||
import { computed } from "vue";
|
||||
import { trans, WORKFLOW_ATTACHMENTS_NO_ATTACHMENT } from "translator";
|
||||
|
||||
interface AttachmentListProps {
|
||||
attachments: WorkflowAttachment[];
|
||||
@@ -14,35 +21,43 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const props = defineProps<AttachmentListProps>();
|
||||
|
||||
const notNullAttachments = computed<AttachmentWithDocAndStored[]>(() =>
|
||||
props.attachments.filter(
|
||||
(a: WorkflowAttachment): a is AttachmentWithDocAndStored =>
|
||||
isAttachmentWithDocAndStored(a),
|
||||
),
|
||||
);
|
||||
|
||||
const canRemove = computed<boolean>((): boolean => {
|
||||
if (null === props.workflow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return props.workflow._permissions.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
v-if="props.attachments.length === 0"
|
||||
v-if="notNullAttachments.length === 0"
|
||||
class="chill-no-data-statement text-center"
|
||||
>
|
||||
Aucune pièce jointe
|
||||
{{ trans(WORKFLOW_ATTACHMENTS_NO_ATTACHMENT) }}
|
||||
</p>
|
||||
<!-- TODO translate -->
|
||||
<div else class="flex-table">
|
||||
<div v-for="a in props.attachments" :key="a.id" class="item-bloc">
|
||||
<div v-else class="flex-table">
|
||||
<div v-for="a in notNullAttachments" :key="a.id" class="item-bloc">
|
||||
<generic-doc-item-box
|
||||
v-if="a.genericDoc !== null"
|
||||
:generic-doc="a.genericDoc"
|
||||
></generic-doc-item-box>
|
||||
<div class="item-row separator">
|
||||
<ul class="record_actions">
|
||||
<li v-if="a.genericDoc?.storedObject !== null">
|
||||
<li>
|
||||
<document-action-buttons-group
|
||||
:stored-object="a.genericDoc.storedObject"
|
||||
></document-action-buttons-group>
|
||||
</li>
|
||||
<li
|
||||
v-if="
|
||||
!workflow?._permissions
|
||||
.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT
|
||||
"
|
||||
>
|
||||
<li v-if="canRemove">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-delete"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
|
||||
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
|
||||
|
||||
interface GenericDocItemBoxProps {
|
||||
genericDoc: GenericDocForAccompanyingPeriod;
|
||||
genericDoc: GenericDoc;
|
||||
}
|
||||
|
||||
const props = defineProps<GenericDocItemBoxProps>();
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{% if chill_main_config.top_banner is defined and chill_main_config.top_banner.text is defined %}
|
||||
{% set banner_text = '' %}
|
||||
{% set current_locale = app.request.locale %}
|
||||
|
||||
{% if chill_main_config.top_banner.text[current_locale] is defined %}
|
||||
{% set banner_text = chill_main_config.top_banner.text[current_locale] %}
|
||||
{% else %}
|
||||
{% set banner_text = chill_main_config.top_banner.text|first %}
|
||||
{% endif %}
|
||||
|
||||
{% if banner_text %}
|
||||
<div class="top-banner w-100 text-center py-2"
|
||||
style="{% if chill_main_config.top_banner.color is defined %}color: {{ chill_main_config.top_banner.color }};{% endif %}{% if chill_main_config.top_banner.background_color is defined %}background-color: {{ chill_main_config.top_banner.background_color }};{% endif %}">
|
||||
{{ banner_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -26,6 +26,10 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% if chill_main_config.top_banner is defined and chill_main_config.top_banner.visible is true %}
|
||||
{{ include('@ChillMain/Layout/_top_banner.html.twig') }}
|
||||
{% endif %}
|
||||
|
||||
{% if responsive_debug is defined and responsive_debug == 1 %}
|
||||
{{ include('@ChillMain/Layout/_debug.html.twig') }}
|
||||
{% endif %}
|
||||
|
||||
@@ -64,7 +64,7 @@ class AddressReferenceBEFromBestAddress
|
||||
|
||||
$uncompressedStream = gzopen($tmpname, 'r');
|
||||
|
||||
$csv = Reader::createFromStream($uncompressedStream);
|
||||
$csv = Reader::from($uncompressedStream);
|
||||
$csv->setDelimiter(',');
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@ final class AddressReferenceBaseImporter
|
||||
|
||||
$filename = sprintf('%s-%s.csv', (new \DateTimeImmutable())->format('Ymd-His'), uniqid());
|
||||
$path = Path::normalize(sprintf('%s%s%s', sys_get_temp_dir(), DIRECTORY_SEPARATOR, $filename));
|
||||
$writer = Writer::createFromPath($path, 'w+');
|
||||
$writer = Writer::from($path, 'w+');
|
||||
// insert headers
|
||||
$writer->insertOne([
|
||||
'postalcode',
|
||||
|
||||
@@ -53,7 +53,7 @@ class AddressReferenceFromBAN
|
||||
// re-open it to read it
|
||||
$csvDecompressed = gzopen($path, 'r');
|
||||
|
||||
$csv = Reader::createFromStream($csvDecompressed);
|
||||
$csv = Reader::from($csvDecompressed);
|
||||
$csv->setDelimiter(';')->setHeaderOffset(0);
|
||||
$stmt = new Statement();
|
||||
$stmt = $stmt->process($csv, [
|
||||
|
||||
@@ -41,7 +41,7 @@ class AddressReferenceFromBano
|
||||
|
||||
fseek($file, 0);
|
||||
|
||||
$csv = Reader::createFromStream($file);
|
||||
$csv = Reader::from($file);
|
||||
$csv->setDelimiter(',');
|
||||
$stmt = new Statement();
|
||||
$stmt = $stmt->process($csv, [
|
||||
|
||||
@@ -39,7 +39,7 @@ class AddressReferenceLU
|
||||
|
||||
fseek($file, 0);
|
||||
|
||||
$csv = Reader::createFromStream($file);
|
||||
$csv = Reader::from($file);
|
||||
$csv->setDelimiter(';');
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ class PostalCodeBEFromBestAddress
|
||||
|
||||
$uncompressedStream = gzopen($tmpname, 'r');
|
||||
|
||||
$csv = Reader::createFromStream($uncompressedStream);
|
||||
$csv = Reader::from($uncompressedStream);
|
||||
$csv->setDelimiter(',');
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ class PostalCodeFRFromOpenData
|
||||
|
||||
fseek($tmpfile, 0);
|
||||
|
||||
$csv = Reader::createFromStream($tmpfile);
|
||||
$csv = Reader::from($tmpfile);
|
||||
$csv->setDelimiter(',');
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ use Symfony\Component\Notifier\Event\SentMessageEvent;
|
||||
final readonly class SentMessageEventSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
private LoggerInterface $notifierLogger, // will be send to "notifierLogger" if it exists
|
||||
) {}
|
||||
|
||||
public static function getSubscribedEvents()
|
||||
@@ -33,9 +33,9 @@ final readonly class SentMessageEventSubscriber implements EventSubscriberInterf
|
||||
$message = $event->getMessage();
|
||||
|
||||
if (null === $message->getMessageId()) {
|
||||
$this->logger->info('[sms] a sms message did not had any id after sending.', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId()]);
|
||||
$this->notifierLogger->info('[sms] a sms message did not had any id after sending.', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId()]);
|
||||
} else {
|
||||
$this->logger->warning('[sms] a sms was sent', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId(), 'idsI' => $message->getMessageId()]);
|
||||
$this->notifierLogger->warning('[sms] a sms was sent', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId(), 'idsI' => $message->getMessageId()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\DependencyInjection;
|
||||
|
||||
use Chill\MainBundle\DependencyInjection\Configuration;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Config\Definition\Processor;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class ConfigurationTest extends TestCase
|
||||
{
|
||||
public function testTopBannerConfiguration(): void
|
||||
{
|
||||
$containerBuilder = new ContainerBuilder();
|
||||
$configuration = new Configuration([], $containerBuilder);
|
||||
$processor = new Processor();
|
||||
|
||||
// Test with top_banner configuration
|
||||
$config = [
|
||||
'chill_main' => [
|
||||
'top_banner' => [
|
||||
'text' => [
|
||||
'fr' => 'Vous travaillez actuellement avec la version de pré-production de Chill.',
|
||||
'nl' => 'Je werkte momenteel in de pré-productie versie van Chill.',
|
||||
],
|
||||
'color' => 'white',
|
||||
'background-color' => 'red',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$processedConfig = $processor->processConfiguration($configuration, $config);
|
||||
|
||||
self::assertArrayHasKey('top_banner', $processedConfig);
|
||||
self::assertArrayHasKey('text', $processedConfig['top_banner']);
|
||||
self::assertArrayHasKey('fr', $processedConfig['top_banner']['text']);
|
||||
self::assertArrayHasKey('nl', $processedConfig['top_banner']['text']);
|
||||
self::assertSame('white', $processedConfig['top_banner']['color']);
|
||||
self::assertSame('red', $processedConfig['top_banner']['background_color']);
|
||||
}
|
||||
|
||||
public function testTopBannerConfigurationOptional(): void
|
||||
{
|
||||
$containerBuilder = new ContainerBuilder();
|
||||
$configuration = new Configuration([], $containerBuilder);
|
||||
$processor = new Processor();
|
||||
|
||||
// Test without top_banner configuration
|
||||
$config = [
|
||||
'chill_main' => [],
|
||||
];
|
||||
|
||||
$processedConfig = $processor->processConfiguration($configuration, $config);
|
||||
|
||||
// top_banner should not be present when not configured
|
||||
self::assertArrayNotHasKey('top_banner', $processedConfig);
|
||||
}
|
||||
|
||||
public function testTopBannerWithMinimalConfiguration(): void
|
||||
{
|
||||
$containerBuilder = new ContainerBuilder();
|
||||
$configuration = new Configuration([], $containerBuilder);
|
||||
$processor = new Processor();
|
||||
|
||||
// Test with minimal top_banner configuration (only text)
|
||||
$config = [
|
||||
'chill_main' => [
|
||||
'top_banner' => [
|
||||
'text' => [
|
||||
'fr' => 'Test message',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$processedConfig = $processor->processConfiguration($configuration, $config);
|
||||
|
||||
self::assertArrayHasKey('top_banner', $processedConfig);
|
||||
self::assertArrayHasKey('text', $processedConfig['top_banner']);
|
||||
self::assertSame('Test message', $processedConfig['top_banner']['text']['fr']);
|
||||
self::assertNull($processedConfig['top_banner']['color']);
|
||||
self::assertNull($processedConfig['top_banner']['background_color']);
|
||||
}
|
||||
}
|
||||
@@ -37,10 +37,5 @@ class DailyNotificationDigestCronJobFunctionalTest extends KernelTestCase
|
||||
$actual = $this->dailyNotificationDigestCronjob->run([]);
|
||||
|
||||
self::assertArrayHasKey('last_execution', $actual);
|
||||
self::assertInstanceOf(
|
||||
\DateTimeImmutable::class,
|
||||
\DateTimeImmutable::createFromFormat('Y-m-d-H:i:s.u e', $actual['last_execution']),
|
||||
'test that the string can be converted to a date'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,16 +12,21 @@ declare(strict_types=1);
|
||||
namespace Chill\MainBundle\Tests\Notification\Email;
|
||||
|
||||
use Chill\MainBundle\Notification\Email\DailyNotificationDigestCronjob;
|
||||
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Result;
|
||||
use Doctrine\DBAL\Statement;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
* @covers \DailyNotificationDigestCronjob
|
||||
*/
|
||||
class DailyNotificationDigestCronJobTest extends TestCase
|
||||
{
|
||||
@@ -30,6 +35,7 @@ class DailyNotificationDigestCronJobTest extends TestCase
|
||||
private MessageBusInterface $messageBus;
|
||||
private LoggerInterface $logger;
|
||||
private DailyNotificationDigestCronjob $cronjob;
|
||||
private \DateTimeImmutable $firstNow;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
@@ -38,6 +44,8 @@ class DailyNotificationDigestCronJobTest extends TestCase
|
||||
$this->messageBus = $this->createMock(MessageBusInterface::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->firstNow = new \DateTimeImmutable('2024-01-02T07:15:00+00:00');
|
||||
|
||||
$this->cronjob = new DailyNotificationDigestCronjob(
|
||||
$this->clock,
|
||||
$this->connection,
|
||||
@@ -78,4 +86,129 @@ class DailyNotificationDigestCronJobTest extends TestCase
|
||||
'hour 23 - should not run' => [23, false],
|
||||
];
|
||||
}
|
||||
|
||||
public function testRunFirstExecutionReturnsStateAndDispatches(): array
|
||||
{
|
||||
// Use MockClock for deterministic time
|
||||
$firstNow = $this->firstNow;
|
||||
$clock = new MockClock($firstNow);
|
||||
|
||||
// Mock DBAL statement/result
|
||||
$statement = $this->createMock(Statement::class);
|
||||
$result = $this->createMock(Result::class);
|
||||
|
||||
$this->connection->method('prepare')->willReturn($statement);
|
||||
$statement->method('bindValue')->willReturnSelf();
|
||||
$statement->method('executeQuery')->willReturn($result);
|
||||
|
||||
$rows = [
|
||||
['user_id' => 10],
|
||||
['user_id' => 42],
|
||||
];
|
||||
$result->method('fetchAllAssociative')->willReturn($rows);
|
||||
|
||||
$dispatched = [];
|
||||
$this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$dispatched) {
|
||||
$dispatched[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
});
|
||||
|
||||
$cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger);
|
||||
$state = $cron->run([]);
|
||||
|
||||
// Assert dispatch count and message contents
|
||||
self::assertCount(2, $dispatched);
|
||||
$expectedLast = $firstNow->sub(new \DateInterval('P1D'));
|
||||
foreach ($dispatched as $i => $msg) {
|
||||
self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg);
|
||||
self::assertTrue(in_array($msg->getUserId(), [10, 42], true));
|
||||
self::assertEquals($firstNow, $msg->getCurrentDateTime(), 'compare the current date');
|
||||
self::assertEquals($expectedLast, $msg->getLastExecutionDateTime(), 'compare the last execution date');
|
||||
}
|
||||
|
||||
// Assert returned state
|
||||
self::assertIsArray($state);
|
||||
self::assertArrayHasKey('last_execution', $state);
|
||||
self::assertSame($firstNow->format(\DateTimeInterface::ATOM), $state['last_execution']);
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
/**
|
||||
* @depends testRunFirstExecutionReturnsStateAndDispatches
|
||||
*/
|
||||
public function testRunSecondExecutionUsesPreviousState(array $previousState): void
|
||||
{
|
||||
$firstNow = $this->firstNow;
|
||||
$secondNow = $firstNow->add(new \DateInterval('P1D'));
|
||||
$clock = new MockClock($secondNow);
|
||||
|
||||
// Mock DBAL for a single user this time
|
||||
$statement = $this->createMock(Statement::class);
|
||||
$result = $this->createMock(Result::class);
|
||||
|
||||
$this->connection->method('prepare')->willReturn($statement);
|
||||
$statement->method('bindValue')->willReturnSelf();
|
||||
$statement->method('executeQuery')->willReturn($result);
|
||||
|
||||
$rows = [
|
||||
['user_id' => 7],
|
||||
];
|
||||
$result->method('fetchAllAssociative')->willReturn($rows);
|
||||
|
||||
$captured = [];
|
||||
$this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$captured) {
|
||||
$captured[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
});
|
||||
|
||||
$cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger);
|
||||
$cron->run($previousState);
|
||||
|
||||
self::assertCount(1, $captured);
|
||||
$msg = $captured[0];
|
||||
self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg);
|
||||
self::assertEquals(7, $msg->getUserId());
|
||||
self::assertEquals($secondNow, $msg->getCurrentDateTime(), 'compare the current date');
|
||||
self::assertEquals($firstNow, $msg->getLastExecutionDateTime(), 'compare the last execution date');
|
||||
}
|
||||
|
||||
public function testRunWithInvalidExecutionState(): void
|
||||
{
|
||||
$firstNow = new \DateTimeImmutable('2025-10-14T10:30:00 Europe/Brussels');
|
||||
$previousExpected = $firstNow->sub(new \DateInterval('P1D'));
|
||||
$clock = new MockClock($firstNow);
|
||||
|
||||
// Mock DBAL for a single user this time
|
||||
$statement = $this->createMock(Statement::class);
|
||||
$result = $this->createMock(Result::class);
|
||||
|
||||
$this->connection->method('prepare')->willReturn($statement);
|
||||
$statement->method('bindValue')->willReturnSelf();
|
||||
$statement->method('executeQuery')->willReturn($result);
|
||||
|
||||
$rows = [
|
||||
['user_id' => 7],
|
||||
];
|
||||
$result->method('fetchAllAssociative')->willReturn($rows);
|
||||
|
||||
$captured = [];
|
||||
$this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$captured) {
|
||||
$captured[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
});
|
||||
|
||||
$cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger);
|
||||
$cron->run(['last_execution' => 'invalid data']);
|
||||
|
||||
self::assertCount(1, $captured);
|
||||
$msg = $captured[0];
|
||||
self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg);
|
||||
self::assertEquals(7, $msg->getUserId());
|
||||
self::assertEquals($firstNow, $msg->getCurrentDateTime(), 'compare the current date');
|
||||
self::assertEquals($previousExpected, $msg->getLastExecutionDateTime(), 'compare the last execution date');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,6 +670,8 @@ workflow:
|
||||
|
||||
attachments:
|
||||
title: Pièces jointes
|
||||
no_attachment: Aucune pièce jointe
|
||||
Add_an_attachment: Ajouter une pièce jointe
|
||||
|
||||
wait:
|
||||
title: En attente de traitement
|
||||
|
||||
@@ -49,7 +49,7 @@ final class ImportSocialWorkMetadata extends Command
|
||||
$filepath = $input->getOption('filepath');
|
||||
|
||||
try {
|
||||
$csv = Reader::createFromPath($filepath);
|
||||
$csv = Reader::from($filepath);
|
||||
} catch (\Throwable $e) {
|
||||
throw new \Exception('Error while loading CSV.', 0, $e);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class LoadSocialWorkMetadata extends Fixture implements OrderedFixtureInterface
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
try {
|
||||
$csv = Reader::createFromPath(__DIR__.'/data/social_work_metadata.csv');
|
||||
$csv = Reader::from(__DIR__.'/data/social_work_metadata.csv');
|
||||
} catch (\Throwable $e) {
|
||||
throw new \Exception('Error while loading CSV.', 0, $e);
|
||||
}
|
||||
|
||||
@@ -38,34 +38,47 @@ final readonly class SocialActionCSVExportService
|
||||
array_keys($this->formatRow(new SocialAction()))
|
||||
);
|
||||
|
||||
$csv = Writer::createFromPath('php://temp', 'w+');
|
||||
$csv = Writer::from('php://temp', 'w+');
|
||||
$csv->insertOne($headers);
|
||||
|
||||
foreach ($actions as $action) {
|
||||
if ($action->getGoals()->isEmpty() && $action->getResults()->isEmpty() && $action->getEvaluations()->isEmpty()) {
|
||||
$hasGoals = !$action->getGoals()->isEmpty();
|
||||
$hasResults = !$action->getResults()->isEmpty();
|
||||
$hasEvaluations = !$action->getEvaluations()->isEmpty();
|
||||
|
||||
// If action has no goals, results, or evaluations, insert a single row
|
||||
if (!$hasGoals && !$hasResults && !$hasEvaluations) {
|
||||
$csv->insertOne($this->formatRow($action));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process goals and their results
|
||||
if ($hasGoals) {
|
||||
foreach ($action->getGoals() as $goal) {
|
||||
if ($goal->getResults()->isEmpty()) {
|
||||
$csv->insertOne($this->formatRow($action, $goal));
|
||||
}
|
||||
|
||||
} else {
|
||||
foreach ($goal->getResults() as $goalResult) {
|
||||
$csv->insertOne($this->formatRow($action, $goal, $goalResult));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process results that are linked to this action (regardless of whether they have goals elsewhere)
|
||||
if ($hasResults && !$hasGoals) {
|
||||
foreach ($action->getResults() as $result) {
|
||||
if ($result->getGoals()->isEmpty()) {
|
||||
$csv->insertOne($this->formatRow($action, null, null, $result));
|
||||
}
|
||||
}
|
||||
|
||||
// Process evaluations
|
||||
if ($hasEvaluations) {
|
||||
foreach ($action->getEvaluations() as $evaluation) {
|
||||
$csv->insertOne($this->formatRow($action, evaluation: $evaluation));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $csv;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ readonly class SocialIssueCSVExportService
|
||||
public function generateCsv(array $issues): Writer
|
||||
{
|
||||
// CSV headers
|
||||
$csv = Writer::createFromPath('php://temp', 'r+');
|
||||
$csv = Writer::from('php://temp', 'r+');
|
||||
$csv->insertOne(
|
||||
array_map(
|
||||
fn (string $e) => $this->translator->trans($e),
|
||||
|
||||
@@ -52,7 +52,7 @@ class ThirdpartyCSVExportController extends AbstractController
|
||||
fwrite($output, "\xEF\xBB\xBF");
|
||||
|
||||
// Create CSV writer
|
||||
$csv = Writer::createFromStream($output);
|
||||
$csv = Writer::from($output);
|
||||
|
||||
// Write header row
|
||||
$header = array_map(
|
||||
|
||||
Reference in New Issue
Block a user