diff --git a/.changes/v4.0.2.md b/.changes/v4.0.2.md new file mode 100644 index 000000000..8761b48f2 --- /dev/null +++ b/.changes/v4.0.2.md @@ -0,0 +1,4 @@ +## v4.0.2 - 2025-07-09 +### Fixed +* Fix add missing translation +* Fix the transfer of evaluations and documents during of accompanyingperiodwork diff --git a/.changes/v4.1.0.md b/.changes/v4.1.0.md new file mode 100644 index 000000000..fd67c0c9a --- /dev/null +++ b/.changes/v4.1.0.md @@ -0,0 +1,12 @@ +## v4.1.0 - 2025-08-26 +### Feature +* ([#400](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/400)) Add filter to social actions list to filter out actions where current user intervenes +* ([#399](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/399)) Show filters on list pages unfolded by default +* Expansion of event module with new fields in the creation form: thematic, internal/external animator, responsable, and budget elements. Filtering options in the event list + adapted exports + + **Schema Change**: Add columns or tables +### Fixed +* ([#382](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/382)) adjust display logic for accompanying period dates, include closing date if period is closed. +* ([#384](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/384)) add min and step attributes to integer field in DateIntervalType +### UX +* Limit display of participations in event list diff --git a/.changes/v4.2.0.md b/.changes/v4.2.0.md new file mode 100644 index 000000000..6e2835ada --- /dev/null +++ b/.changes/v4.2.0.md @@ -0,0 +1,10 @@ +## v4.2.0 - 2025-09-02 +### Feature +* ([#64](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/64)) Add external identifier for a Person + + **Schema Change**: Add columns or tables +* ([#330](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/330) Allow users to choose for which notifications they want to receive an email +### Fixed +* ([#422](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/422)) Fixed html layout of pages for recovering password +* Fix typo in 'uncheckAll' script for centers selection +* Fix incorrect parameter name in event details link diff --git a/.changes/v4.2.1.md b/.changes/v4.2.1.md new file mode 100644 index 000000000..c2dbf65a6 --- /dev/null +++ b/.changes/v4.2.1.md @@ -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 + diff --git a/.eslint-baseline.json b/.eslint-baseline.json index 7b5d29c67..a8bf01c0d 100644 --- a/.eslint-baseline.json +++ b/.eslint-baseline.json @@ -17,19 +17,19 @@ }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/Components/CalendarActive.vue", - "line": 10, - "column": 22, + "line": 19, + "column": 30, "ruleId": "vue/valid-v-else", "message": "'v-else' directives require no attribute value.", - "hash": "c4d34a0df38baaf72092179b2e066c91b6f6733a" + "hash": "505f99b24500a684eec3c6bf870426fcd841c20b" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/Components/CalendarActive.vue", - "line": 45, + "line": 54, "column": 10, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'mapGetters' is defined but never used.", - "hash": "8e80aff377fdd5af9686df6a89a4ba650911f02a" + "hash": "01e3928f7d9be0accb6db3894b158dd683a685ff" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/index.js", @@ -82,50 +82,50 @@ { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/utils.ts", "line": 4, - "column": 3, + "column": 5, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'DateTime' is defined but never used.", - "hash": "88be21db222be347a0cf7f8fbfaba531d5cb0418" + "hash": "fdf38ad15813c4a931b93f4a85db985ca71105fc" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/utils.ts", "line": 14, - "column": 25, + "column": 27, "ruleId": "@typescript-eslint/no-empty-object-type", "message": "The `{}` (\"empty object\") type allows any non-nullish value, including literals like `0` and `\"\"`.\n- If that's what you want, disable this lint rule with an inline comment or configure the 'allowObjectTypes' rule option.\n- If you want a type meaning \"any object\", you probably want `object` instead.\n- If you want a type meaning \"any value\", you probably want `unknown` instead.", - "hash": "6c1437cbb501093bde9c9f7eeda4e4853e558c1a" + "hash": "9602ec11eda8f46ff5712d69dcd87419babcd6f0" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/utils.ts", "line": 16, - "column": 18, + "column": 20, "ruleId": "@typescript-eslint/no-empty-object-type", "message": "The `{}` (\"empty object\") type allows any non-nullish value, including literals like `0` and `\"\"`.\n- If that's what you want, disable this lint rule with an inline comment or configure the 'allowObjectTypes' rule option.\n- If you want a type meaning \"any object\", you probably want `object` instead.\n- If you want a type meaning \"any value\", you probably want `unknown` instead.", - "hash": "f03d8114bfe562d84f8160f5d122562e484b5de1" + "hash": "23f7a8a1c3cba955077ddbd604bad6805ce30ad2" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/utils.ts", "line": 18, - "column": 17, + "column": 19, "ruleId": "@typescript-eslint/no-empty-object-type", "message": "The `{}` (\"empty object\") type allows any non-nullish value, including literals like `0` and `\"\"`.\n- If that's what you want, disable this lint rule with an inline comment or configure the 'allowObjectTypes' rule option.\n- If you want a type meaning \"any object\", you probably want `object` instead.\n- If you want a type meaning \"any value\", you probably want `unknown` instead.", - "hash": "de706baa154ead33e72ce520c50cdd16a85d1265" + "hash": "abbc89e186c74d0f71f45cfda31c490339e39078" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Invite/Answer.vue", - "line": 86, - "column": 19, + "line": 95, + "column": 13, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'payload' is defined but never used.", - "hash": "8c189da3c258c22dfdd1e0a72d0f0ef5464c26eb" + "hash": "66c545917093ba30f1d6ca10ddaa676140e749bd" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/Components/EditLocation.vue", "line": 77, - "column": 12, + "column": 16, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'_' is defined but never used.", - "hash": "8040b05fb0422e91d049dd0d7fb4604b4455bf5e" + "hash": "d29fb2fc9299c48082167b2fa4c427c569716bd6" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/Components/EditLocation.vue", @@ -154,50 +154,50 @@ { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/index2.ts", "line": 12, - "column": 9, + "column": 11, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'app' is assigned a value but never used.", - "hash": "3ebdc6637bc62a230176a4dff37a5900061e6eed" + "hash": "6422c0876b992a3c5174faa99ef06d8339b74b4e" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/index.ts", - "line": 44, - "column": 74, + "line": 46, + "column": 20, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'_' is defined but never used.", - "hash": "7ac3276b20b1029e510bcbbbc8572fc34400e204" + "hash": "5bf7ba0b0200fb6788d50df669e6b7701f8a87bb" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/fullcalendar.ts", "line": 57, - "column": 20, + "column": 32, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'_' is defined but never used.", - "hash": "d25bbc0a04099c361d99f34b0c731892c7b008a7" + "hash": "a733a5edfe4c434ed63682bcbd5fe65f175a1b85" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/fullcalendar.ts", "line": 64, - "column": 20, + "column": 32, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'_' is defined but never used.", - "hash": "7c53ad6034cebccfe119a1ee7dd011984a720676" + "hash": "d83b1879b5fc6179c045ad1981421f46a7cbbd56" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/fullcalendar.ts", "line": 71, - "column": 20, + "column": 32, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'_' is defined but never used.", - "hash": "110e12e976ae0f8fe15cd70b974d99ee381bb6fb" + "hash": "222eed84495212735a60fa05e0c7f298c7bc33e1" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/fullcalendar.ts", "line": 72, - "column": 18, + "column": 26, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'_' is defined but never used.", - "hash": "7919b6b2becd817e0fad8c51602b0f8c2fc0f5f2" + "hash": "0cf85eccce08b65a8590f7318cace58cbb21ea08" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/store/modules/me.ts", @@ -210,90 +210,90 @@ { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_components/CalendarUserSelector/CalendarUserSelector.vue", "line": 108, - "column": 35, + "column": 47, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'reject' is defined but never used.", - "hash": "e57ca97296d09e44a8e19573138a4f39f7777778" - }, - { - "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_components/CalendarUserSelector/CalendarUserSelector.vue", - "line": 145, - "column": 15, - "ruleId": "vue/no-mutating-props", - "message": "Unexpected mutation of \"users\" prop.", - "hash": "26378799855b04bc15aa3e030b9ede95f3139c4d" + "hash": "1a771f9f65375cac2002ed7d706184355685d378" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_components/CalendarUserSelector/CalendarUserSelector.vue", "line": 148, - "column": 15, + "column": 29, "ruleId": "vue/no-mutating-props", - "message": "Unexpected mutation of \"calendarEvents\" prop.", - "hash": "279aee22cdc99740903c9fee736897a65074a8bb" + "message": "Unexpected mutation of \"users\" prop.", + "hash": "d9f7e517892b6588be17516fd077c3149ccbefd9" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_components/CalendarUserSelector/CalendarUserSelector.vue", "line": 151, - "column": 41, - "ruleId": "@typescript-eslint/no-unused-vars", - "message": "'reject' is defined but never used.", - "hash": "2c597422f93f4b397bfe96365007c602d349ad0c" - }, - { - "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_components/CalendarUserSelector/CalendarUserSelector.vue", - "line": 152, - "column": 21, - "ruleId": "vue/no-mutating-props", - "message": "Unexpected mutation of \"users\" prop.", - "hash": "75af3ee3d935ce4d62c037461e552cef3ab0f291" - }, - { - "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_components/CalendarUserSelector/CalendarUserSelector.vue", - "line": 158, - "column": 47, - "ruleId": "@typescript-eslint/no-unused-vars", - "message": "'reject' is defined but never used.", - "hash": "6ffe789bbd1d731daaf5c2bff4582372fd342dc1" - }, - { - "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_components/CalendarUserSelector/CalendarUserSelector.vue", - "line": 170, - "column": 27, + "column": 29, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"calendarEvents\" prop.", - "hash": "ef1795af6c50719c19ec3bffd8e511d06a9bfac2" + "hash": "47c270d8c0bcda67ea04a83ba267eca3e70fa7fb" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_components/CalendarUserSelector/CalendarUserSelector.vue", - "line": 197, - "column": 9, + "line": 154, + "column": 59, + "ruleId": "@typescript-eslint/no-unused-vars", + "message": "'reject' is defined but never used.", + "hash": "7cf30f0d2dc567ea0dfb44dadfc9ecf4977e7b95" + }, + { + "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_components/CalendarUserSelector/CalendarUserSelector.vue", + "line": 155, + "column": 41, + "ruleId": "vue/no-mutating-props", + "message": "Unexpected mutation of \"users\" prop.", + "hash": "7876348a3eee16be7effd3e992a6aaa2334cfd24" + }, + { + "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_components/CalendarUserSelector/CalendarUserSelector.vue", + "line": 164, + "column": 63, + "ruleId": "@typescript-eslint/no-unused-vars", + "message": "'reject' is defined but never used.", + "hash": "0f308d7417bfc294b28eea6890666a2d91e31ecd" + }, + { + "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_components/CalendarUserSelector/CalendarUserSelector.vue", + "line": 185, + "column": 57, + "ruleId": "vue/no-mutating-props", + "message": "Unexpected mutation of \"calendarEvents\" prop.", + "hash": "5a021a581a33d3aeea21c65e023902f2123bb562" + }, + { + "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_components/CalendarUserSelector/CalendarUserSelector.vue", + "line": 218, + "column": 17, "ruleId": "@typescript-eslint/prefer-for-of", "message": "Expected a `for-of` loop instead of a `for` loop with this simple iteration.", - "hash": "bc4a3fd50f5afd095d8e938885291d948c4ba689" + "hash": "655811d4dad8de23b3d37dfbf02f7fca18b6d8f2" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_components/CalendarUserSelector/CalendarUserSelector.vue", - "line": 214, - "column": 7, + "line": 235, + "column": 13, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"calendarEvents\" prop.", - "hash": "6a27b96c77fc2747b4fd0f82ccfedc44138b0aa5" + "hash": "19317d9a564b8b10355cac1eeb9308c7d49c0dd1" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_components/CalendarUserSelector/CalendarUserSelector.vue", - "line": 219, - "column": 7, + "line": 240, + "column": 13, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"users\" prop.", - "hash": "61c0ca5de675766483584b7363b048356f9c55f8" + "hash": "95e478eca53a2acfa8edd0aa5747e73b0da01d0c" }, { "path": "src/Bundle/ChillCalendarBundle/Resources/public/vuejs/_components/CalendarUserSelector/CalendarUserSelector.vue", - "line": 225, - "column": 7, + "line": 246, + "column": 13, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"users\" prop.", - "hash": "d87ce5e7fbcefc884fb7be907d3c3fef53921ab4" + "hash": "0b66e38f0c0bbe17aa7c548dc127be033133ce06" }, { "path": "src/Bundle/ChillDocStoreBundle/Resources/public/js/async-upload/uploader.ts", @@ -489,11 +489,11 @@ }, { "path": "src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue", - "line": 78, - "column": 3, + "line": 80, + "column": 5, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'StoredObjectVersion' is defined but never used.", - "hash": "da2ec95bcd2bb80819ef2032e40b0d7237acba31" + "hash": "2fadbe9e331a66f44b54782acc1420c4b487e1f6" }, { "path": "src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentSignature/index.ts", @@ -522,10 +522,10 @@ { "path": "src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/ConvertButton.vue", "line": 11, - "column": 3, + "column": 5, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'download_and_decrypt_doc' is defined but never used.", - "hash": "ddc9ed7c384b9da6ed1398551cefe7d244cb0c9e" + "hash": "9a803f1fe608ec60ab065aa8f76e50e2ef1fa4c2" }, { "path": "src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/ConvertButton.vue", @@ -537,11 +537,11 @@ }, { "path": "src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/ConvertButton.vue", - "line": 48, - "column": 9, + "line": 50, + "column": 11, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'reset_pending' is assigned a value but never used.", - "hash": "9919afcb6b2724f5b6be69caa1b487026b9260fe" + "hash": "96fde3634a150f1253fac2f2f2bdcfe6e0daf82a" }, { "path": "src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton.vue", @@ -561,19 +561,19 @@ }, { "path": "src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/WopiEditButton.vue", - "line": 13, + "line": 15, "column": 8, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'WopiEditButton' is defined but never used.", - "hash": "f197263cc1b8a5eeadd87e2e2c44eccc618375ec" + "hash": "80cb06a18ac4db47e8d120103704ec6988696b79" }, { "path": "src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/helpers.ts", "line": 3, - "column": 3, + "column": 5, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'StoredObjectStatus' is defined but never used.", - "hash": "ceb106361b5267900ff8852f6ff191698e065e13" + "hash": "e868cee022cd1499bb54f7b5503a8a822773e097" }, { "path": "src/Bundle/ChillJobBundle/src/Resources/public/module/cv_edit/index.js", @@ -697,27 +697,27 @@ }, { "path": "src/Bundle/ChillMainBundle/Resources/public/chill/js/date.ts", - "line": 71, - "column": 3, + "line": 73, + "column": 5, "ruleId": "prefer-const", "message": "'cal' is never reassigned. Use 'const' instead.", - "hash": "f6bce9a9d62ca55fec76a414f36ad445ffbb5f17" + "hash": "53586ea7ec719f07750a8874db79f5e76583d5e0" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/chill/js/date.ts", - "line": 77, - "column": 3, + "line": 79, + "column": 5, "ruleId": "prefer-const", "message": "'time' is never reassigned. Use 'const' instead.", - "hash": "7bb4fae8b43da52f82f65b50a87b22d0375b517f" + "hash": "19821279c0b2d7e69418db6877c317f8c6e2dacc" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/chill/js/date.ts", - "line": 83, - "column": 3, + "line": 85, + "column": 5, "ruleId": "prefer-const", "message": "'offset' is never reassigned. Use 'const' instead.", - "hash": "f91a4b071f6ad88a8408dfa241b3cec00c7d54db" + "hash": "244b65de0ab0791ec89219058c5cb4f2e11622c7" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/lib/download-report/download-report.js", @@ -850,26 +850,26 @@ { "path": "src/Bundle/ChillMainBundle/Resources/public/module/collection/index.ts", "line": 141, - "column": 3, + "column": 5, "ruleId": "@typescript-eslint/prefer-for-of", "message": "Expected a `for-of` loop instead of a `for` loop with this simple iteration.", - "hash": "c280bbe030fe9a793f531ce00c1a4a6c6fdcfdc0" + "hash": "cd5fc994be294fde5fef27ba841c9af454b81dc0" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/module/collection/index.ts", "line": 153, - "column": 3, + "column": 5, "ruleId": "@typescript-eslint/prefer-for-of", "message": "Expected a `for-of` loop instead of a `for` loop with this simple iteration.", - "hash": "98e7c2f525eddcfd8d0f839c9bf69b7c56c9d537" + "hash": "381830dd078845aeab5ea366757541cd7c77ca3f" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/module/collection/index.ts", "line": 162, - "column": 5, + "column": 9, "ruleId": "@typescript-eslint/prefer-for-of", "message": "Expected a `for-of` loop instead of a `for` loop with this simple iteration.", - "hash": "66a4dba7d1d666cbac1ed22eb2a7916cf97ca5ec" + "hash": "768b1976fb935d4a4b0890f00be2299b7ff60170" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/module/disable-buttons/index.js", @@ -994,514 +994,418 @@ { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/App.vue", "line": 79, - "column": 39, + "column": 55, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'reject' is defined but never used.", - "hash": "3014fecdefbab379cc54e6abe7532c892cc788f6" + "hash": "29ea9ef3f1b2cb970e8103383bcde12a4ab66c25" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/App.vue", "line": 101, - "column": 39, + "column": 55, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'reject' is defined but never used.", - "hash": "03336dded33c9831e1f8c4b950591b0a24eac678" + "hash": "b81ee6947049c6876f81410cfbb499dc43da4374" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue", - "line": 244, - "column": 3, - "ruleId": "@typescript-eslint/no-unused-vars", - "message": "'postAddressToPerson' is defined but never used.", - "hash": "0a60babcbc24fd0a410901c25994e93f1cd88acc" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue", - "line": 245, - "column": 3, - "ruleId": "@typescript-eslint/no-unused-vars", - "message": "'postAddressToHousehold' is defined but never used.", - "hash": "6022bfb8f8d94a3ce0fb2aca7a846c084c2170af" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue", - "line": 486, - "column": 11, + "line": 516, + "column": 21, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"context\" prop.", - "hash": "26b7b1a24fd66b1763e131ad372ea9e9246ed2ca" + "hash": "984c4203f2ac1e1bb65f9ce76ecd03b763cfaa83" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue", - "line": 487, - "column": 11, + "line": 517, + "column": 21, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"context\" prop.", - "hash": "1c8c8b1de3e43ec62a8193b0fa6efdfeade74677" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue", - "line": 501, - "column": 35, - "ruleId": "@typescript-eslint/no-unused-vars", - "message": "'reject' is defined but never used.", - "hash": "eca7f49f652654fcffdbe10ac410f108ff3fb28e" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue", - "line": 518, - "column": 35, - "ruleId": "@typescript-eslint/no-unused-vars", - "message": "'reject' is defined but never used.", - "hash": "31097431dfeb6ebcaa0893d89aa5309de9ee774e" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue", - "line": 546, - "column": 35, - "ruleId": "@typescript-eslint/no-unused-vars", - "message": "'reject' is defined but never used.", - "hash": "142f4ce9e45cf10df3d74345496c2a8919a138bc" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue", - "line": 565, - "column": 35, - "ruleId": "@typescript-eslint/no-unused-vars", - "message": "'reject' is defined but never used.", - "hash": "e9973c9eb1f151f71f6386d2a0ce83f322ff2cf5" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue", - "line": 787, - "column": 35, - "ruleId": "@typescript-eslint/no-unused-vars", - "message": "'reject' is defined but never used.", - "hash": "78f1d0e8d3b2fa2489f4e311db2d1215d77b1c46" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue", - "line": 836, - "column": 35, - "ruleId": "@typescript-eslint/no-unused-vars", - "message": "'reject' is defined but never used.", - "hash": "cbabc9b1434c9a5690bc269aabd001c8841aeb1b" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressMore.vue", - "line": 93, - "column": 9, - "ruleId": "vue/no-mutating-props", - "message": "Unexpected mutation of \"entity\" prop.", - "hash": "9c66b7ec84cc786856badd8498414379ef4056ed" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressMore.vue", - "line": 101, - "column": 9, - "ruleId": "vue/no-mutating-props", - "message": "Unexpected mutation of \"entity\" prop.", - "hash": "92d482ada1514212e710c366a7700111eda96f1e" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressMore.vue", - "line": 109, - "column": 9, - "ruleId": "vue/no-mutating-props", - "message": "Unexpected mutation of \"entity\" prop.", - "hash": "f5c902853071084dfe7fd9a1c50e1c653e4c6a19" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressMore.vue", - "line": 117, - "column": 9, - "ruleId": "vue/no-mutating-props", - "message": "Unexpected mutation of \"entity\" prop.", - "hash": "409c69e33cdf563b7bc2d0c074f413f8d12c0307" + "hash": "c9fb019bc21bfa77d989ed596913b99dd653c594" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressMore.vue", "line": 125, - "column": 9, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "ea78eb9d3d49445fc131568291b4d777e2e8d271" + "hash": "792310bc5def2c7b45f50da97cd8818d82862e8a" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressMore.vue", "line": 133, - "column": 9, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "db792e379fcff91ef9388fbf88492552885efb66" + "hash": "a69e5335393f67a83d89720af34a4385a7d7e665" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressMore.vue", "line": 141, - "column": 9, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "dbba8af7379ffc65a6d80b4fffd00ca658251fbe" + "hash": "2d5a5e680ff207ad97c7e7b7d999064b561dfd8a" }, { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", - "line": 104, - "column": 9, + "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressMore.vue", + "line": 149, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "7c28ce533e2011e4f052d5ca0c7d57610b0461da" + "hash": "e4c1ecd7ae77d46ac3625c5bbe92a24d6a964db9" }, { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", - "line": 112, - "column": 9, + "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressMore.vue", + "line": 157, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "c40ccbcab1e9ceccd5a350ddd88ff7dd5c468ec3" + "hash": "4dece2db87c6ce1c04ae06c088ddfe916c1c0c61" }, { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", - "line": 126, - "column": 7, + "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressMore.vue", + "line": 165, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "f407cd7f81bc5d33ed0831a592870074e6a4d6f1" + "hash": "facc7a0f17bdf19396fae3d0de3da82e60503c0d" }, { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", - "line": 127, - "column": 7, + "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressMore.vue", + "line": 173, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "5d974adadc72bf10bfa1a863bdc44fdc43c2207f" + "hash": "19de32c76518387218264d7c4dab914d143a9cca" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", "line": 130, - "column": 7, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "1fbda9c797af826dbadcfd16dc945b8d21e3ddf0" + "hash": "239ac02a02694d5b20ab30d4c7ce5838c51d1515" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", - "line": 131, - "column": 7, + "line": 138, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "b0ff83b3f077f1a6143654e5914321e3cf29ac95" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", - "line": 132, - "column": 7, - "ruleId": "vue/no-mutating-props", - "message": "Unexpected mutation of \"entity\" prop.", - "hash": "19b7e3498b9e90d6a86b3be74338d040247465ab" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", - "line": 137, - "column": 7, - "ruleId": "vue/no-mutating-props", - "message": "Unexpected mutation of \"entity\" prop.", - "hash": "d7c45fd73ad1721055f23116102655bdd90a9528" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", - "line": 151, - "column": 39, - "ruleId": "@typescript-eslint/no-unused-vars", - "message": "'reject' is defined but never used.", - "hash": "bb2767a02ef0c4cd4598702d06ee95d7e79fced3" + "hash": "a54f9bc6d1edfa4df93c7dd7d409cfef3fccf99e" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", "line": 152, - "column": 19, + "column": 13, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "ce6c91b98e94605fe9c5b6c4e358a42da79a3150" + "hash": "74a5f664d18f3916ea908897fcd0291cb0128f29" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", - "line": 168, + "line": 153, + "column": 13, + "ruleId": "vue/no-mutating-props", + "message": "Unexpected mutation of \"entity\" prop.", + "hash": "740ea5d793c7a34c9f352d8b333f3aa04cc80ee8" + }, + { + "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", + "line": 156, + "column": 13, + "ruleId": "vue/no-mutating-props", + "message": "Unexpected mutation of \"entity\" prop.", + "hash": "af8aca18f0226a5988ed90d44d95e2d607bfb5e6" + }, + { + "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", + "line": 157, + "column": 13, + "ruleId": "vue/no-mutating-props", + "message": "Unexpected mutation of \"entity\" prop.", + "hash": "7bc2453017793ae20cd6c10005f941d384b59d84" + }, + { + "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", + "line": 158, + "column": 13, + "ruleId": "vue/no-mutating-props", + "message": "Unexpected mutation of \"entity\" prop.", + "hash": "571b4ee5f22358dd165ec59696bb3439b7c9ff6c" + }, + { + "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", + "line": 163, + "column": 13, + "ruleId": "vue/no-mutating-props", + "message": "Unexpected mutation of \"entity\" prop.", + "hash": "cfcb5946c86e289fc61623a794284a5a272d02e8" + }, + { + "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", + "line": 178, + "column": 37, + "ruleId": "vue/no-mutating-props", + "message": "Unexpected mutation of \"entity\" prop.", + "hash": "0ec402e43cb08bf129e0737c0d2c4f6d0c7af8bd" + }, + { + "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", + "line": 196, "column": 41, - "ruleId": "@typescript-eslint/no-unused-vars", - "message": "'reject' is defined but never used.", - "hash": "28a03efbb5ade5a575393d46f4ec87fd3ab3af57" + "ruleId": "vue/no-mutating-props", + "message": "Unexpected mutation of \"entity\" prop.", + "hash": "ec178d33e067aac892e015002afb6f3a2ff98762" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", - "line": 169, - "column": 21, + "line": 214, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "7eede2e283dcaeeeba01fba092100c0839eacb37" + "hash": "c0f4e5454e672b6064eb9cf6c235c6810f7bfa80" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", - "line": 186, - "column": 9, + "line": 215, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "a7ff3bc5466588198108718a166980b73e87ee40" + "hash": "e3dd840d2474f9865a45822872bf9ecfb15961d7" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", - "line": 187, - "column": 9, + "line": 216, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "c87deccee6f42a977abbf54b25c12af2543c4ba6" + "hash": "a32a60382b145cc7a4a7ebe01ec435b8e3103320" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", - "line": 188, - "column": 9, + "line": 246, + "column": 13, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "83029bfbb4c6e6e76f097dedad5c95b7efbf1d54" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressSelection.vue", - "line": 218, - "column": 7, - "ruleId": "vue/no-mutating-props", - "message": "Unexpected mutation of \"entity\" prop.", - "hash": "f8cc21c434f3a41e97c1e659f3e3e41bc8ea3015" + "hash": "082447e5c731012f3acc282943502775dfd24797" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", - "line": 96, - "column": 14, + "line": 118, + "column": 20, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "9f4994768baeedde467d8a6a594eddb96c8d4f35" + "hash": "d4fba4fe09af3c0937c0dd164928c8930c1591b5" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", - "line": 96, - "column": 14, + "line": 118, + "column": 20, "ruleId": "vue/no-side-effects-in-computed-properties", "message": "Unexpected side effect in \"cities\" computed property.", - "hash": "efdf2f0fa3127580834a1a71c05cc412cdffc17a" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", - "line": 102, - "column": 9, - "ruleId": "vue/no-mutating-props", - "message": "Unexpected mutation of \"entity\" prop.", - "hash": "331da5bdcdaca996b5a86bbf9d074172d9a97de5" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", - "line": 110, - "column": 9, - "ruleId": "vue/no-mutating-props", - "message": "Unexpected mutation of \"entity\" prop.", - "hash": "1503a521d5aac26de71708193d5efe381fce1a91" + "hash": "1113a114d5aaf9f32f442916d25458541c5af35c" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", "line": 124, - "column": 7, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "b7099b57a857083cd18ffca20e489fcc894675f0" + "hash": "fa56a7c93583f0a9d0c2ecac10228c4f4fc1bc3a" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", - "line": 125, - "column": 7, + "line": 132, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "577790a8bd8c6081fe75e4d4ebbb99d251e7cf5a" + "hash": "9fe87937ea67d1dae95fb3d44d4be0da2eba0905" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", - "line": 126, - "column": 7, + "line": 146, + "column": 13, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "744ba1d220a69ca2c63c9b7dc1478ef71af0ec8e" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", - "line": 131, - "column": 11, - "ruleId": "vue/no-mutating-props", - "message": "Unexpected mutation of \"entity\" prop.", - "hash": "e8134808053567607222487fcb4144183e0ffe86" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", - "line": 143, - "column": 7, - "ruleId": "vue/no-mutating-props", - "message": "Unexpected mutation of \"entity\" prop.", - "hash": "fab57266818588096de456916ebe3fa88e89a4e4" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", - "line": 144, - "column": 7, - "ruleId": "vue/no-mutating-props", - "message": "Unexpected mutation of \"entity\" prop.", - "hash": "f3d04b5c0a3a29bfa65e7eeeb1c50e989ec4821e" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", - "line": 145, - "column": 7, - "ruleId": "vue/no-mutating-props", - "message": "Unexpected mutation of \"entity\" prop.", - "hash": "90b3e9a1ba5be62bd6894796ecc2e33e2f29329d" + "hash": "eaaaaee5fb2e324ffe0a68eefe340dabdf162324" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", "line": 147, - "column": 9, + "column": 13, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "8a90e844f524ba01cc1c64806eeda052ab88957d" + "hash": "2de47b4a4ddbe546b3fce9898af48b72853364bf" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", - "line": 149, - "column": 7, + "line": 148, + "column": 13, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "e83c3118bc20776c00437d7edc3deaf24915bd66" + "hash": "ab4f478fbfbc954b8dff75176dcd432f9ff28cfc" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", - "line": 158, - "column": 7, + "line": 153, + "column": 21, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "378ccd9fa7cf74e41c905f1a21bdbf1f74c4015f" + "hash": "1d907d149f9ddb62e32140a90efe9a74b3e71fef" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", "line": 167, - "column": 37, - "ruleId": "@typescript-eslint/no-unused-vars", - "message": "'reject' is defined but never used.", - "hash": "5bbe7140e1370b411abbdfc0554d58d62472d0d7" + "column": 13, + "ruleId": "vue/no-mutating-props", + "message": "Unexpected mutation of \"entity\" prop.", + "hash": "8aa37d2d4f011773e68838a2c88017875de563b5" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", "line": 168, + "column": 13, + "ruleId": "vue/no-mutating-props", + "message": "Unexpected mutation of \"entity\" prop.", + "hash": "a4827a357e52a51fa9262319114d81a130296acf" + }, + { + "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", + "line": 169, + "column": 13, + "ruleId": "vue/no-mutating-props", + "message": "Unexpected mutation of \"entity\" prop.", + "hash": "a4c9715664202949e3242b8d4aa4098288b46dc4" + }, + { + "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", + "line": 171, "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "b712ccec63c177608e199b9bc63f30bba6e6ecfe" + "hash": "f3e9e21e433e90ec7b615b8940d43c4177372b66" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", - "line": 186, - "column": 39, - "ruleId": "@typescript-eslint/no-unused-vars", - "message": "'reject' is defined but never used.", - "hash": "9a5b009d8e7f30e61c1991c97d390f0735d19968" - }, - { - "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", - "line": 187, - "column": 19, + "line": 174, + "column": 13, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "831be7f044c74b2c1a4244ffe3a065dd21e6247d" + "hash": "770b7a24cc24b380e88db47d62422c8e1ece2571" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", - "line": 205, - "column": 9, + "line": 183, + "column": 13, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "6c0fc8e55e3132c27cbaeabd2e84ca2838008247" + "hash": "2aef3c519a9ec6abcfe7573989d3de19d5c4c752" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", - "line": 206, - "column": 9, + "line": 193, + "column": 33, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "4b66a3e1cf5476e4a0e2f1b3f3546222572abf8f" + "hash": "5d1f97e4d7d9f47399d312e8b9f95ef9e3843b8c" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", - "line": 207, - "column": 9, + "line": 213, + "column": 37, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "c685acbb0d48bab6c55b0af2dcc701f5edf993fe" + "hash": "c1df874f790ef0c036bf58ae8a8db1ee173685d4" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", - "line": 208, - "column": 9, + "line": 232, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "0059a00f9459b29cb4a54eb2b273d9f59d7890af" + "hash": "476e6588a28ac9382e8b9d2e63a8babecd23bad8" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", - "line": 240, - "column": 7, + "line": 233, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "e66f10f1d6116821b3f0fb066bcbf50772ba5374" + "hash": "6a0c82ba72d6d87217bf33a6ad8e40a4b81bc802" + }, + { + "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", + "line": 234, + "column": 17, + "ruleId": "vue/no-mutating-props", + "message": "Unexpected mutation of \"entity\" prop.", + "hash": "741d5af6c7d90041c0dc1c1df2e8699b80fca69a" + }, + { + "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", + "line": 235, + "column": 17, + "ruleId": "vue/no-mutating-props", + "message": "Unexpected mutation of \"entity\" prop.", + "hash": "c3ffd141f58d532663875cc5c7d338ed00db2a6d" + }, + { + "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CitySelection.vue", + "line": 267, + "column": 13, + "ruleId": "vue/no-mutating-props", + "message": "Unexpected mutation of \"entity\" prop.", + "hash": "2700f258396516a2fe971618fafbcdf72cdda3ab" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CountrySelection.vue", - "line": 72, - "column": 7, + "line": 94, + "column": 13, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "037d3140ebf15fc52a0f58f7844a9a2af44a3d9c" + "hash": "4be1b0592efa775092a91a1d744e16ce98bd216e" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/CountrySelection.vue", - "line": 77, - "column": 7, + "line": 99, + "column": 13, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "936e5eed1f826a51d129d97580bfb5084a6a4a48" + "hash": "19b54b6d76c30249d520a296f826eda9d6eb0668" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/DatePane.vue", - "line": 92, - "column": 9, + "line": 95, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "2058a778c66e3d4005cdfb4354ede1d93e8e1ab6" + "hash": "84e13d1fdc79f4568634a78df281adbe81739cbd" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/DatePane.vue", - "line": 100, - "column": 9, + "line": 103, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "20b24f03b3260f915df456ce4d54775f2dda68b6" + "hash": "1eed832462e52537402a2825655733f0f2d391d9" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/EditPane.vue", - "line": 152, - "column": 9, + "line": 169, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "849bea4394a838c6f9c75cb93a29049d9c7a3c9e" + "hash": "dcb7b34098062760ddbb849655a5bb3ca65c36d3" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/EditPane.vue", - "line": 161, - "column": 9, + "line": 178, + "column": 17, "ruleId": "vue/no-mutating-props", "message": "Unexpected mutation of \"entity\" prop.", - "hash": "56abd4aa323bb35e94cbd0264603b6d1848298d1" + "hash": "86b3ecf201025cac36878c5e4bf8850fb9d58cb5" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/index.js", @@ -1529,11 +1433,19 @@ }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/HomepageWidget/DashboardWidgets/NewsItem.vue", - "line": 136, - "column": 55, + "line": 142, + "column": 57, "ruleId": "@typescript-eslint/no-explicit-any", "message": "Unexpected any. Specify a different type.", - "hash": "2ca5e2fd1fccea428a772ad7d2dd0d001bdc413b" + "hash": "a68eeba7b2e1e603d83da0946c94cd221134aa99" + }, + { + "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/OnTheFly/components/OnTheFly.vue", + "line": 204, + "column": 22, + "ruleId": "vue/return-in-computed-property", + "message": "Expected to return a value in \"buttonMessage\" computed property.", + "hash": "b101c861dc11bc7024857fa2977118cb9f99c02c" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/OnTheFly/index.js", @@ -1545,51 +1457,67 @@ }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/AddressDetails/Parts/AddressDetailsMap.vue", - "line": 21, + "line": 24, "column": 13, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'LatLngExpression' is defined but never used.", - "hash": "96b45c0371542f6e2a5b6f1b2d4f598698faff68" + "hash": "78f5a83dddf05b38aa9472ab93871e976719ef30" + }, + { + "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/GenderIconRenderBox.vue", + "line": 6, + "column": 7, + "ruleId": "@typescript-eslint/no-unused-vars", + "message": "'props' is assigned a value but never used.", + "hash": "29fe0a5d52e46c479aa2e7bb23fb55c53df7b22e" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/PickWorkflow.vue", - "line": 107, - "column": 32, + "line": 114, + "column": 34, "ruleId": "vue/require-valid-default-prop", "message": "Type of the default value for 'goToGenerateWorkflowPayload' prop must be a function.", - "hash": "d86b61c318c09e12544ded19f252f6e281e8f985" + "hash": "d686fa87cfdc801aaaa08b24e827e503e81e86be" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/PickWorkflow.vue", - "line": 163, + "line": 170, "column": 7, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'goToDuplicateRelatedEntity' is assigned a value but never used.", - "hash": "a6b7b632e663f282e0f4951d95a6987fa70f4046" + "hash": "224ddf3abcff96e3e20a0facc7493883958d5a80" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/PickWorkflow.vue", - "line": 164, - "column": 3, + "line": 171, + "column": 5, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'event' is defined but never used.", - "hash": "dd3f0de245d7a2107ad5526965d1a0c29df0ef26" + "hash": "aa87fd5511528b5a45713fe1eaeda9ae0a8c0975" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/PickWorkflow.vue", - "line": 165, - "column": 3, + "line": 172, + "column": 5, "ruleId": "@typescript-eslint/no-unused-vars", "message": "'workflowName' is defined but never used.", - "hash": "d12891a4cc2df02d4b15f30b474edf8b01fd9766" + "hash": "e34bbcf245552e9329efdf7bd64ea3a56f0d4538" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/PickWorkflow.vue", - "line": 166, + "line": 173, "column": 12, "ruleId": "@typescript-eslint/no-empty-function", "message": "Unexpected empty arrow function.", - "hash": "3977a54eb58bc5c558dfb1ce043b14377a746441" + "hash": "8bdff7a5b3a7ac1506966a6066a1deb556d30efe" + }, + { + "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Modal.vue", + "line": 3, + "column": 9, + "ruleId": "vue/require-toggle-inside-transition", + "message": "The element inside `` is expected to have a `v-if` or `v-show` directive.", + "hash": "0594fb9d0984f4dd1612671aca21b571087ab8ee" }, { "path": "src/Bundle/ChillMainBundle/Resources/public/vuejs/_js/i18n.ts", @@ -1710,5 +1638,21 @@ "ruleId": "@typescript-eslint/no-unused-vars", "message": "'app' is assigned a value but never used.", "hash": "9e94e6412b8a44e47bfe8e66218cad09cff5bed4" + }, + { + "path": "src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeUserGroup.vue", + "line": 6, + "column": 8, + "ruleId": "@typescript-eslint/no-unused-vars", + "message": "'BadgeEntity' is defined but never used.", + "hash": "951a1b012bdec10c4b859af8b34dd894f63add23" + }, + { + "path": "src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeUserGroup.vue", + "line": 7, + "column": 8, + "ruleId": "@typescript-eslint/no-unused-vars", + "message": "'UserRenderBoxBadge' is defined but never used.", + "hash": "99eba0d8633b2c9497417f4f61ec4194dbb2a96b" } -] +] \ No newline at end of file diff --git a/.gitignore b/.gitignore index 88458b64e..be72f296f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ migrations/* templates/* translations/* +# we allow developers to add customization on their installation, without commiting it +config/packages/dev/* + ###> symfony/framework-bundle ### /.env.local /.env.local.php diff --git a/.junie/guidelines.md b/.junie/guidelines.md index eace2f4fa..97a2be27d 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -185,14 +185,57 @@ When we need to use a DateTime or DateTimeImmutable that need to express "now", `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. +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. + ### Testing Information The project uses PHPUnit for testing. Each bundle has its own test suite, and there's also a global test suite at the root level. +#### Use of mock in tests + +##### General mocking + For creating mock, we prefer using prophecy (library phpspec/prophecy). +##### Useful helpers and tips that avoid create a mock + +Some notable implementations that are tests helper, and avoid to create a mock: + +- `\Psr\Log\NullLogger`, an implementation of `\Psr\Log\LoggerInterface`; +- `\Symfony\Component\Clock\MockClock`, an implementation of `Symfony\Component\Clock\ClockInterface` (already mentioned above); +- `\Symfony\Component\HttpClient\MockHttpClient`, an implementation of `\Symfony\Contracts\HttpClient\HttpClientInterface`; +- When using `\Symfony\Component\Mailer\MailerInterface`, we can create the mock with "InMemoryTransport": + + ```php + use Symfony\Component\Mailer\Transport\InMemoryTransport; + use \Symfony\Component\Mailer\Mailer; + + $transport = new InMemoryTransport(); + $mailer = new Mailer($transport); + + // After sending: + $messages = $transport->getSent(); // array of SentMessage + ``` +- When using `\Symfony\Contracts\EventDispatcher\EventDispatcherInterface`, we can use directly an instance of `\Symfony\Component\EventDispatcher\EventDispatcher`; + +##### When we prefer not creating a mock + +- When we use Doctrine Entities related to the project, we prefer not to use a mock: we instantiate them directly (unless it requires too much code to write); + +##### Mocking final and readonly classes + +Classes marked as final can't be mocked. To avoid that, either: + +- we remove the `final` keyword from the class; +- we extract an interface from the final class. + +This must be a decision made by a human, not by an AI. Every AI task must abort with an explicit message in that case. + #### Running Tests +The tests are run from the project's root (not from the bundle's root). + ```bash # Run all tests vendor/bin/phpunit diff --git a/CHANGELOG.md b/CHANGELOG.md index 78941a951..67db93f05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,42 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), and is generated by [Changie](https://github.com/miniscruff/changie). +## v4.2.1 - 2025-09-03 +### Fixed +* Fix exports to work with DirectExportInterface +### DX +* Improve error message when a stored object cannot be written on local disk + + +## v4.2.0 - 2025-09-02 +### Feature +* ([#64](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/64)) Add external identifier for a Person + + **Schema Change**: Add columns or tables +* ([#330](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/330) Allow users to choose for which notifications they want to receive an email +### Fixed +* ([#422](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/422)) Fixed html layout of pages for recovering password +* Fix typo in 'uncheckAll' script for centers selection +* Fix incorrect parameter name in event details link + +## v4.1.0 - 2025-08-26 +### Feature +* ([#400](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/400)) Add filter to social actions list to filter out actions where current user intervenes +* ([#399](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/399)) Show filters on list pages unfolded by default +* Expansion of event module with new fields in the creation form: thematic, internal/external animator, responsable, and budget elements. Filtering options in the event list + adapted exports + + **Schema Change**: Add columns or tables +### Fixed +* ([#382](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/382)) adjust display logic for accompanying period dates, include closing date if period is closed. +* ([#384](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/384)) add min and step attributes to integer field in DateIntervalType +### UX +* Limit display of participations in event list + +## v4.0.2 - 2025-07-09 +### Fixed +* Fix add missing translation +* Fix the transfer of evaluations and documents during of accompanyingperiodwork + ## v4.0.1 - 2025-07-08 ### Fixed * Fix package.json for compilation diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 39eab3875..4274aeec6 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -62,8 +62,10 @@ framework: 'Chill\MainBundle\Workflow\Messenger\PostSignatureStateChangeMessage': priority 'Chill\MainBundle\Workflow\Messenger\PostPublicViewMessage': async 'Chill\MainBundle\Service\Workflow\CancelStaleWorkflowMessage': async + 'Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage': async 'Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage': priority 'Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessage': async + 'Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage': async # end of routes added by chill-bundles recipes # Route your messages to the transports # 'App\Message\YourMessage': async diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php index 31b870ed4..1820aa3bf 100644 --- a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php @@ -24,7 +24,11 @@ use Doctrine\ORM\EntityManagerInterface; class CalendarForShortMessageProvider { - public function __construct(private readonly CalendarRepository $calendarRepository, private readonly EntityManagerInterface $em, private readonly RangeGeneratorInterface $rangeGenerator) {} + public function __construct( + private readonly CalendarRepository $calendarRepository, + private readonly EntityManagerInterface $em, + private readonly RangeGeneratorInterface $rangeGenerator, + ) {} /** * Generate calendars instance. diff --git a/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php index 47af7d68e..79f06b434 100644 --- a/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php +++ b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php @@ -21,7 +21,6 @@ namespace Chill\CalendarBundle\Tests\Service\ShortMessageNotification; use Chill\CalendarBundle\Entity\Calendar; use Chill\CalendarBundle\Repository\CalendarRepository; use Chill\CalendarBundle\Service\ShortMessageNotification\CalendarForShortMessageProvider; -use Chill\CalendarBundle\Service\ShortMessageNotification\DefaultRangeGenerator; use Chill\CalendarBundle\Service\ShortMessageNotification\RangeGeneratorInterface; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; @@ -82,10 +81,16 @@ final class CalendarForShortMessageProviderTest extends TestCase $em = $this->prophesize(EntityManagerInterface::class); $em->clear()->shouldBeCalled(); + $calendarRangeGenerator = $this->prophesize(RangeGeneratorInterface::class); + $calendarRangeGenerator->generateRange(Argument::any())->willReturn([ + 'startDate' => new \DateTimeImmutable('yesterday'), + 'endDate' => new \DateTimeImmutable('now'), + ]); + $provider = new CalendarForShortMessageProvider( $calendarRepository->reveal(), $em->reveal(), - new DefaultRangeGenerator() + $calendarRangeGenerator->reveal(), ); $calendars = iterator_to_array($provider->getCalendars(new \DateTimeImmutable('now'))); @@ -103,26 +108,32 @@ final class CalendarForShortMessageProviderTest extends TestCase Argument::type(\DateTimeImmutable::class), Argument::type('int'), Argument::exact(0) - )->will(static fn ($args) => array_fill(0, 1, new Calendar()))->shouldBeCalledTimes(1); + )->will(static fn ($args) => array_fill(0, 10, new Calendar()))->shouldBeCalledTimes(1); $calendarRepository->findByNotificationAvailable( Argument::type(\DateTimeImmutable::class), Argument::type(\DateTimeImmutable::class), Argument::type('int'), - Argument::not(0) + Argument::exact(10) )->will(static fn ($args) => [])->shouldBeCalledTimes(1); $em = $this->prophesize(EntityManagerInterface::class); $em->clear()->shouldBeCalled(); + $calendarRangeGenerator = $this->prophesize(RangeGeneratorInterface::class); + $calendarRangeGenerator->generateRange(Argument::any())->willReturn([ + 'startDate' => new \DateTimeImmutable('yesterday'), + 'endDate' => new \DateTimeImmutable('now'), + ]); + $provider = new CalendarForShortMessageProvider( $calendarRepository->reveal(), $em->reveal(), - new DefaultRangeGenerator() + $calendarRangeGenerator->reveal(), ); $calendars = iterator_to_array($provider->getCalendars(new \DateTimeImmutable('now'))); - $this->assertEquals(1, \count($calendars)); + $this->assertEquals(10, \count($calendars)); $this->assertContainsOnly(Calendar::class, $calendars); } } diff --git a/src/Bundle/ChillCustomFieldsBundle/EntityRepository/CustomFieldsDefaultGroupRepository.php b/src/Bundle/ChillCustomFieldsBundle/EntityRepository/CustomFieldsDefaultGroupRepository.php new file mode 100644 index 000000000..a336970dc --- /dev/null +++ b/src/Bundle/ChillCustomFieldsBundle/EntityRepository/CustomFieldsDefaultGroupRepository.php @@ -0,0 +1,29 @@ +findOneBy(['entity' => $className]); + } +} diff --git a/src/Bundle/ChillCustomFieldsBundle/config/services.yaml b/src/Bundle/ChillCustomFieldsBundle/config/services.yaml index d27323a0a..bdc447996 100644 --- a/src/Bundle/ChillCustomFieldsBundle/config/services.yaml +++ b/src/Bundle/ChillCustomFieldsBundle/config/services.yaml @@ -127,3 +127,7 @@ services: factory: ["@doctrine", getRepository] arguments: - "Chill\\CustomFieldsBundle\\Entity\\CustomFieldLongChoice\\Option" + + Chill\CustomFieldsBundle\EntityRepository\CustomFieldsDefaultGroupRepository: + autowire: true + autoconfigure: true diff --git a/src/Bundle/ChillDocStoreBundle/AsyncUpload/Driver/LocalStorage/StoredObjectManager.php b/src/Bundle/ChillDocStoreBundle/AsyncUpload/Driver/LocalStorage/StoredObjectManager.php index 09d277635..9174f42e6 100644 --- a/src/Bundle/ChillDocStoreBundle/AsyncUpload/Driver/LocalStorage/StoredObjectManager.php +++ b/src/Bundle/ChillDocStoreBundle/AsyncUpload/Driver/LocalStorage/StoredObjectManager.php @@ -18,6 +18,7 @@ use Chill\DocStoreBundle\Exception\StoredObjectManagerException; use Chill\DocStoreBundle\Service\Cryptography\KeyGenerator; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; +use Symfony\Component\Filesystem\Exception\IOExceptionInterface; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Path; @@ -147,16 +148,11 @@ class StoredObjectManager implements StoredObjectManagerInterface public function writeContent(string $filename, string $encryptedContent): void { $fullPath = $this->buildPath($filename); - $dir = Path::getDirectory($fullPath); - if (!$this->filesystem->exists($dir)) { - $this->filesystem->mkdir($dir); - } - - $result = file_put_contents($fullPath, $encryptedContent); - - if (false === $result) { - throw StoredObjectManagerException::unableToStoreDocumentOnDisk(); + try { + $this->filesystem->dumpFile($fullPath, $encryptedContent); + } catch (IOExceptionInterface $exception) { + throw StoredObjectManagerException::unableToStoreDocumentOnDisk($exception); } } diff --git a/src/Bundle/ChillEventBundle/Controller/Admin/EventBudgetKindController.php b/src/Bundle/ChillEventBundle/Controller/Admin/EventBudgetKindController.php new file mode 100644 index 000000000..063ca4c0d --- /dev/null +++ b/src/Bundle/ChillEventBundle/Controller/Admin/EventBudgetKindController.php @@ -0,0 +1,28 @@ +addOrderBy('e.type', 'ASC'); + + return parent::orderQuery($action, $query, $request, $paginator); + } +} diff --git a/src/Bundle/ChillEventBundle/Controller/EventController.php b/src/Bundle/ChillEventBundle/Controller/EventController.php index b4f099769..7bcc8dbe6 100644 --- a/src/Bundle/ChillEventBundle/Controller/EventController.php +++ b/src/Bundle/ChillEventBundle/Controller/EventController.php @@ -23,11 +23,11 @@ use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Form\Type\PickPersonDynamicType; use Chill\PersonBundle\Privacy\PrivacyEvent; +use Doctrine\Persistence\ManagerRegistry; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Writer\Csv; use PhpOffice\PhpSpreadsheet\Writer\Ods; use PhpOffice\PhpSpreadsheet\Writer\Xlsx; -use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\EventDispatcher\EventDispatcherInterface; @@ -41,6 +41,8 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\Exception\ExceptionInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Contracts\Translation\TranslatorInterface; /** @@ -58,7 +60,8 @@ final class EventController extends AbstractController private readonly TranslatorInterface $translator, private readonly PaginatorFactory $paginator, private readonly Security $security, - private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry, + private readonly ManagerRegistry $managerRegistry, + private readonly NormalizerInterface $normalizer, ) {} #[\Symfony\Component\Routing\Annotation\Route(path: '/{_locale}/event/event/{event_id}/delete', name: 'chill_event__event_delete', requirements: ['event_id' => '\d+'], methods: ['GET', 'POST', 'DELETE'])] @@ -75,6 +78,7 @@ final class EventController extends AbstractController /** @var array $participations */ $participations = $event->getParticipations(); + $budgetElements = $event->getBudgetElements(); $form = $this->createDeleteForm($event_id); @@ -86,6 +90,10 @@ final class EventController extends AbstractController $em->remove($participation); } + foreach ($budgetElements as $e) { + $em->remove($e); + } + $em->remove($event); $em->flush(); @@ -103,7 +111,7 @@ final class EventController extends AbstractController } return $this->render('@ChillEvent/Event/confirm_delete.html.twig', [ - 'event_id' => $event->getId(), + 'id' => $event->getId(), 'delete_form' => $form->createView(), ]); } @@ -169,6 +177,8 @@ final class EventController extends AbstractController /** * Displays a form to create a new Event entity. + * + * @throws ExceptionInterface */ #[\Symfony\Component\Routing\Annotation\Route(path: '/{_locale}/event/event/new', name: 'chill_event__event_new', methods: ['GET', 'POST'])] public function newAction(?Center $center, Request $request): Response @@ -199,26 +209,23 @@ final class EventController extends AbstractController $this->addFlash('success', $this->translator ->trans('The event was created')); - return $this->redirectToRoute('chill_event__event_show', ['event_id' => $entity->getId()]); + return $this->redirectToRoute('chill_event__event_show', ['id' => $entity->getId()]); } + $entity_array = $this->normalizer->normalize($entity, 'json', ['groups' => 'read']); + return $this->render('@ChillEvent/Event/new.html.twig', [ 'entity' => $entity, 'form' => $form->createView(), + 'entity_json' => $entity_array, ]); } - /** - * First step of new Event form. - */ #[\Symfony\Component\Routing\Annotation\Route(path: '/{_locale}/event/event/new/pick-center', name: 'chill_event__event_new_pickcenter', options: [null])] public function newPickCenterAction(): Response { $role = 'CHILL_EVENT_CREATE'; - /** - * @var Center $centers - */ $centers = $this->authorizationHelper->getReachableCenters($this->getUser(), $role); if (1 === \count($centers)) { @@ -238,7 +245,7 @@ final class EventController extends AbstractController ->add('center_id', EntityType::class, [ 'class' => Center::class, 'choices' => $centers, - 'placeholder' => '', + 'placeholder' => $this->translator->trans('Pick a center'), 'label' => 'To which centre should the event be associated ?', ]) ->add('submit', SubmitType::class, [ @@ -251,16 +258,7 @@ final class EventController extends AbstractController ]); } - /** - * Finds and displays a Event entity. - * - * @ParamConverter("event", options={"id": "event_id"}) - * - * @return Response - * - * @throws \PhpOffice\PhpSpreadsheet\Exception - */ - #[\Symfony\Component\Routing\Annotation\Route(path: '/{_locale}/event/event/{event_id}/show', name: 'chill_event__event_show')] + #[\Symfony\Component\Routing\Annotation\Route(path: '/{_locale}/event/event/{id}/show', name: 'chill_event__event_show')] public function showAction(Event $event, Request $request) { if (!$event) { @@ -317,7 +315,7 @@ final class EventController extends AbstractController $this->addFlash('success', $this->translator->trans('The event was updated')); - return $this->redirectToRoute('chill_event__event_show', ['event_id' => $event_id]); + return $this->redirectToRoute('chill_event__event_show', ['id' => $event_id]); } return $this->render('@ChillEvent/Event/edit.html.twig', [ diff --git a/src/Bundle/ChillEventBundle/Controller/EventListController.php b/src/Bundle/ChillEventBundle/Controller/EventListController.php index 692611adc..e88c28b35 100644 --- a/src/Bundle/ChillEventBundle/Controller/EventListController.php +++ b/src/Bundle/ChillEventBundle/Controller/EventListController.php @@ -15,11 +15,15 @@ use Chill\EventBundle\Entity\Event; use Chill\EventBundle\Entity\EventType; use Chill\EventBundle\Repository\EventACLAwareRepositoryInterface; use Chill\EventBundle\Repository\EventTypeRepository; +use Chill\EventBundle\Security\EventVoter; +use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Pagination\PaginatorFactoryInterface; +use Chill\MainBundle\Security\Authorization\AuthorizationHelper; use Chill\MainBundle\Templating\Listing\FilterOrderHelper; use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactory; use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Chill\PersonBundle\Form\Type\PickPersonDynamicType; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\FormFactoryInterface; @@ -29,17 +33,18 @@ use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Twig\Environment; -final readonly class EventListController +final class EventListController extends AbstractController { public function __construct( - private Environment $environment, - private EventACLAwareRepositoryInterface $eventACLAwareRepository, - private EventTypeRepository $eventTypeRepository, - private FilterOrderHelperFactory $filterOrderHelperFactory, - private FormFactoryInterface $formFactory, - private PaginatorFactoryInterface $paginatorFactory, - private TranslatableStringHelperInterface $translatableStringHelper, - private UrlGeneratorInterface $urlGenerator, + private readonly Environment $environment, + private readonly EventACLAwareRepositoryInterface $eventACLAwareRepository, + private readonly EventTypeRepository $eventTypeRepository, + private readonly FilterOrderHelperFactory $filterOrderHelperFactory, + private readonly FormFactoryInterface $formFactory, + private readonly PaginatorFactoryInterface $paginatorFactory, + private readonly TranslatableStringHelperInterface $translatableStringHelper, + private readonly UrlGeneratorInterface $urlGenerator, + private readonly AuthorizationHelper $authorizationHelper, ) {} #[Route(path: '{_locale}/event/event/list', name: 'chill_event_event_list')] @@ -50,6 +55,8 @@ final readonly class EventListController 'q' => (string) $filter->getQueryString(), 'dates' => $filter->getDateRangeData('dates'), 'event_types' => $filter->getEntityChoiceData('event_types'), + 'responsables' => $filter->getUserPickerData('responsables'), + 'centers' => $filter->getEntityChoiceData('centers'), ]; $total = $this->eventACLAwareRepository->countAllViewable($filterData); $pagination = $this->paginatorFactory->create($total); @@ -73,6 +80,7 @@ final readonly class EventListController private function buildFilterOrder(): FilterOrderHelper { $types = $this->eventTypeRepository->findAllActive(); + $centers = $this->authorizationHelper->getReachableCenters($this->getUser(), EventVoter::SEE); $builder = $this->filterOrderHelperFactory->create(__METHOD__); $builder @@ -80,6 +88,16 @@ final readonly class EventListController ->addSearchBox(['name']) ->addEntityChoice('event_types', 'event.filter.event_types', EventType::class, $types, [ 'choice_label' => fn (EventType $e) => $this->translatableStringHelper->localize($e->getName()), + 'expanded' => false, + 'required' => false, + 'attr' => ['class' => 'select2'], + ]) + ->addUserPicker('responsables', 'event.filter.pick_responsable', ['multiple' => true, 'required' => false]) + ->addEntityChoice('centers', 'event.filter.center', Center::class, $centers, [ + 'choice_label' => fn (Center $c) => $c->getName(), + 'expanded' => false, + 'required' => false, + 'attr' => ['class' => 'select2'], ]); return $builder->build(); diff --git a/src/Bundle/ChillEventBundle/Controller/EventThemeController.php b/src/Bundle/ChillEventBundle/Controller/EventThemeController.php new file mode 100644 index 000000000..027b99241 --- /dev/null +++ b/src/Bundle/ChillEventBundle/Controller/EventThemeController.php @@ -0,0 +1,44 @@ + 'create']); + } + + if ('edit' === $action) { + return parent::createFormFor($action, $entity, $formClass, ['step' => 'edit']); + } + + throw new \LogicException('action is not supported: '.$action); + } + + /** + * @param QueryBuilder|mixed $query + */ + protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator): QueryBuilder + { + /* @var QueryBuilder $query */ + return $query->orderBy('e.ordering', 'ASC') + ->addOrderBy('e.id', 'ASC'); + } +} diff --git a/src/Bundle/ChillEventBundle/Controller/ParticipationController.php b/src/Bundle/ChillEventBundle/Controller/ParticipationController.php index 2449528de..7fac83bf5 100644 --- a/src/Bundle/ChillEventBundle/Controller/ParticipationController.php +++ b/src/Bundle/ChillEventBundle/Controller/ParticipationController.php @@ -228,7 +228,7 @@ final class ParticipationController extends AbstractController } return $this->redirectToRoute('chill_event__event_show', [ - 'event_id' => $participation->getEvent()->getId(), + 'id' => $participation->getEvent()->getId(), ]); } @@ -242,7 +242,7 @@ final class ParticipationController extends AbstractController /** * @param int $participation_id */ - #[\Symfony\Component\Routing\Annotation\Route(path: '/{_locale}/event/participation/{participation_id}/delete', name: 'chill_event_participation_delete', requirements: ['participation_id' => '\d+'], methods: ['GET', 'DELETE'])] + #[\Symfony\Component\Routing\Annotation\Route(path: '/{_locale}/event/participation/{participation_id}/delete', name: 'chill_event_participation_delete', requirements: ['participation_id' => '\d+'])] public function deleteAction($participation_id, Request $request): Response|\Symfony\Component\HttpFoundation\RedirectResponse { $em = $this->managerRegistry->getManager(); @@ -273,7 +273,7 @@ final class ParticipationController extends AbstractController ); return $this->redirectToRoute('chill_event__event_show', [ - 'event_id' => $event->getId(), + 'id' => $event->getId(), ]); } } @@ -442,7 +442,7 @@ final class ParticipationController extends AbstractController )); return $this->redirectToRoute('chill_event__event_show', [ - 'event_id' => $participation->getEvent()->getId(), + 'id' => $participation->getEvent()->getId(), ]); } diff --git a/src/Bundle/ChillEventBundle/DependencyInjection/ChillEventExtension.php b/src/Bundle/ChillEventBundle/DependencyInjection/ChillEventExtension.php index 0b30ca6c5..cb59bb390 100644 --- a/src/Bundle/ChillEventBundle/DependencyInjection/ChillEventExtension.php +++ b/src/Bundle/ChillEventBundle/DependencyInjection/ChillEventExtension.php @@ -11,6 +11,12 @@ declare(strict_types=1); namespace Chill\EventBundle\DependencyInjection; +use Chill\EventBundle\Controller\Admin\EventBudgetKindController; +use Chill\EventBundle\Controller\EventThemeController; +use Chill\EventBundle\Entity\EventBudgetKind; +use Chill\EventBundle\Entity\EventTheme; +use Chill\EventBundle\Form\EventBudgetKindType; +use Chill\EventBundle\Form\EventThemeType; use Chill\EventBundle\Security\EventVoter; use Chill\EventBundle\Security\ParticipationVoter; use Symfony\Component\Config\FileLocator; @@ -26,7 +32,10 @@ use Symfony\Component\HttpKernel\DependencyInjection\Extension; */ class ChillEventExtension extends Extension implements PrependExtensionInterface { - public function load(array $configs, ContainerBuilder $container) + /** + * @throws \Exception + */ + public function load(array $configs, ContainerBuilder $container): void { $configuration = new Configuration(); $config = $this->processConfiguration($configuration, $configs); @@ -45,16 +54,17 @@ class ChillEventExtension extends Extension implements PrependExtensionInterface /** (non-PHPdoc). * @see \Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface::prepend() */ - public function prepend(ContainerBuilder $container) + public function prepend(ContainerBuilder $container): void { $this->prependAuthorization($container); + $this->prependCruds($container); $this->prependRoute($container); } /** * add authorization hierarchy. */ - protected function prependAuthorization(ContainerBuilder $container) + protected function prependAuthorization(ContainerBuilder $container): void { $container->prependExtensionConfig('security', [ 'role_hierarchy' => [ @@ -70,7 +80,7 @@ class ChillEventExtension extends Extension implements PrependExtensionInterface /** * add route to route loader for chill. */ - protected function prependRoute(ContainerBuilder $container) + protected function prependRoute(ContainerBuilder $container): void { // add routes for custom bundle $container->prependExtensionConfig('chill_main', [ @@ -81,4 +91,54 @@ class ChillEventExtension extends Extension implements PrependExtensionInterface ], ]); } + + protected function prependCruds(ContainerBuilder $container): void + { + $container->prependExtensionConfig('chill_main', [ + 'cruds' => [ + [ + 'class' => EventTheme::class, + 'name' => 'event_theme', + 'base_path' => '/admin/event/theme', + 'form_class' => EventThemeType::class, + 'controller' => EventThemeController::class, + 'actions' => [ + 'index' => [ + 'template' => '@ChillEvent/Admin/EventTheme/index.html.twig', + 'role' => 'ROLE_ADMIN', + ], + 'new' => [ + 'role' => 'ROLE_ADMIN', + 'template' => '@ChillEvent/Admin/EventTheme/new.html.twig', + ], + 'edit' => [ + 'role' => 'ROLE_ADMIN', + 'template' => '@ChillEvent/Admin/EventTheme/edit.html.twig', + ], + ], + ], + [ + 'class' => EventBudgetKind::class, + 'name' => 'event_budget_kind', + 'base_path' => '/admin/event/budget', + 'form_class' => EventBudgetKindType::class, + 'controller' => EventBudgetKindController::class, + 'actions' => [ + 'index' => [ + 'template' => '@ChillEvent/Admin/BudgetKind/index.html.twig', + 'role' => 'ROLE_ADMIN', + ], + 'new' => [ + 'role' => 'ROLE_ADMIN', + 'template' => '@ChillEvent/Admin/BudgetKind/new.html.twig', + ], + 'edit' => [ + 'role' => 'ROLE_ADMIN', + 'template' => '@ChillEvent/Admin/BudgetKind/edit.html.twig', + ], + ], + ], + ], + ]); + } } diff --git a/src/Bundle/ChillEventBundle/Entity/BudgetTypeEnum.php b/src/Bundle/ChillEventBundle/Entity/BudgetTypeEnum.php new file mode 100644 index 000000000..a0a02455c --- /dev/null +++ b/src/Bundle/ChillEventBundle/Entity/BudgetTypeEnum.php @@ -0,0 +1,18 @@ + + */ + #[ORM\ManyToMany(targetEntity: User::class)] + #[Serializer\Groups(['read'])] + #[ORM\JoinTable('chill_event_animatorsintern')] + private Collection $animatorsIntern; + + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: ThirdParty::class)] + #[Serializer\Groups(['read'])] + #[ORM\JoinTable('chill_event_animatorsextern')] + private Collection $animatorsExtern; + #[Assert\NotBlank] - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 150)] + #[Serializer\Groups(['read'])] + #[ORM\Column(type: Types::STRING, length: 150)] private ?string $name = null; /** * @var Collection */ #[ORM\OneToMany(mappedBy: 'event', targetEntity: Participation::class)] + #[Serializer\Groups(['read'])] private Collection $participations; #[Assert\NotNull] + #[Serializer\Groups(['read'])] #[ORM\ManyToOne(targetEntity: EventType::class)] private ?EventType $type = null; + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: EventTheme::class)] + #[Serializer\Groups(['read'])] + #[ORM\JoinTable('chill_event_eventtheme')] + private Collection $themes; + #[ORM\Embedded(class: CommentEmbeddable::class, columnPrefix: 'comment_')] private CommentEmbeddable $comment; #[ORM\ManyToOne(targetEntity: Location::class)] + #[Serializer\Groups(['read'])] #[ORM\JoinColumn(nullable: true)] private ?Location $location = null; @@ -85,7 +116,17 @@ class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInter #[ORM\JoinTable('chill_event_event_documents')] private Collection $documents; - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DECIMAL, precision: 10, scale: 4, nullable: true, options: ['default' => '0.0'])] + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'event', targetEntity: EventBudgetElement::class, cascade: ['persist'])] + #[Serializer\Groups(['read'])] + private Collection $budgetElements; + + /** + * @deprecated use budgetElements instead + */ + #[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 4, nullable: true, options: ['default' => '0.0'])] private string $organizationCost = '0.0'; /** @@ -96,6 +137,20 @@ class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInter $this->participations = new ArrayCollection(); $this->documents = new ArrayCollection(); $this->comment = new CommentEmbeddable(); + $this->themes = new ArrayCollection(); + $this->budgetElements = new ArrayCollection(); + $this->animatorsIntern = new ArrayCollection(); + $this->animatorsExtern = new ArrayCollection(); + } + + public function addBudgetElement(EventBudgetElement $budgetElement) + { + if (!$this->budgetElements->contains($budgetElement)) { + $this->budgetElements[] = $budgetElement; + $budgetElement->setEvent($this); + } + + return $this; } /** @@ -126,38 +181,79 @@ class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInter return $this; } - /** - * @return Center - */ - public function getCenter() + public function getThemes(): Collection + { + return $this->themes; + } + + public function addTheme(EventTheme $theme): self + { + $this->themes->add($theme); + + return $this; + } + + public function removeTheme(EventTheme $theme): void + { + $this->themes->removeElement($theme); + } + + public function getAnimatorsIntern(): Collection + { + return $this->animatorsIntern; + } + + public function getAnimatorsExtern(): Collection + { + return $this->animatorsExtern; + } + + public function addAnimatorsIntern(User $ai): self + { + $this->animatorsIntern->add($ai); + + return $this; + } + + public function removeAnimatorsIntern(User $ai): void + { + $this->animatorsIntern->removeElement($ai); + } + + public function addAnimatorsExtern(ThirdParty $ae): self + { + $this->animatorsExtern->add($ae); + + return $this; + } + + public function removeAnimatorsExtern(ThirdParty $ae): void + { + $this->animatorsExtern->removeElement($ae); + } + + public function getCenter(): Center { return $this->center; } - /** - * @return Scope - */ - public function getCircle() + public function getCircle(): ?Scope { return $this->circle; } /** * Get date. - * - * @return \DateTime */ - public function getDate() + public function getDate(): ?\DateTime { return $this->date; } /** * Get id. - * - * @return int */ - public function getId() + public function getId(): ?int { return $this->id; } @@ -169,14 +265,20 @@ class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInter /** * Get label. - * - * @return string */ - public function getName() + public function getName(): ?string { return $this->name; } + /** + * @return Collection + */ + public function getBudgetElements(): Collection + { + return $this->budgetElements; + } + /** * @return Collection */ @@ -199,26 +301,26 @@ class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInter /** * @deprecated - * - * @return Scope */ - public function getScope() + public function getScope(): Scope { return $this->getCircle(); } - /** - * @return EventType - */ - public function getType() + public function getType(): ?EventType { return $this->type; } + public function removeBudgetElement(EventBudgetElement $budgetElement): void + { + $this->budgetElements->removeElement($budgetElement); + } + /** * Remove participation. */ - public function removeParticipation(Participation $participation) + public function removeParticipation(Participation $participation): void { $this->participations->removeElement($participation); } @@ -314,11 +416,17 @@ class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInter $this->documents = $documents; } + /** + * @deprecated + */ public function getOrganizationCost(): string { return $this->organizationCost; } + /** + * @deprecated + */ public function setOrganizationCost(string $organizationCost): void { $this->organizationCost = $organizationCost; diff --git a/src/Bundle/ChillEventBundle/Entity/EventBudgetElement.php b/src/Bundle/ChillEventBundle/Entity/EventBudgetElement.php new file mode 100644 index 000000000..ae2fcb1be --- /dev/null +++ b/src/Bundle/ChillEventBundle/Entity/EventBudgetElement.php @@ -0,0 +1,103 @@ +id; + } + + public function setId(?int $id): void + { + $this->id = $id; + } + + public function getAmount(): float + { + return (float) $this->amount; + } + + public function getComment(): ?CommentEmbeddable + { + return $this->comment; + } + + public function getEvent(): Event + { + return $this->event; + } + + public function getKind(): EventBudgetKind + { + return $this->kind; + } + + public function setAmount(string $amount): self + { + $this->amount = $amount; + + return $this; + } + + public function setComment(?CommentEmbeddable $comment = null): self + { + $this->comment = $comment; + + return $this; + } + + public function setEvent(Event $event): self + { + $this->event = $event; + + return $this; + } + + public function setKind(EventBudgetKind $kind): self + { + $this->kind = $kind; + + return $this; + } +} diff --git a/src/Bundle/ChillEventBundle/Entity/EventBudgetKind.php b/src/Bundle/ChillEventBundle/Entity/EventBudgetKind.php new file mode 100644 index 000000000..b668ff5cc --- /dev/null +++ b/src/Bundle/ChillEventBundle/Entity/EventBudgetKind.php @@ -0,0 +1,78 @@ + true])] + private bool $isActive = true; + + #[ORM\Column(enumType: BudgetTypeEnum::class)] + private BudgetTypeEnum $type; + + #[ORM\Column(type: Types::JSON, length: 255, options: ['default' => '{}', 'jsonb' => true])] + private array $name = []; + + public function getId(): ?int + { + return $this->id; + } + + public function getIsActive(): bool + { + return $this->isActive; + } + + public function getType(): BudgetTypeEnum + { + return $this->type; + } + + public function getName(): ?array + { + return $this->name; + } + + public function setIsActive(bool $isActive): self + { + $this->isActive = $isActive; + + return $this; + } + + public function setType(BudgetTypeEnum $type): self + { + $this->type = $type; + + return $this; + } + + public function setName(array $name): self + { + $this->name = $name; + + return $this; + } +} diff --git a/src/Bundle/ChillEventBundle/Entity/EventTheme.php b/src/Bundle/ChillEventBundle/Entity/EventTheme.php new file mode 100644 index 000000000..1a0461cbd --- /dev/null +++ b/src/Bundle/ChillEventBundle/Entity/EventTheme.php @@ -0,0 +1,158 @@ + + */ + #[ORM\OneToMany(mappedBy: 'parent', targetEntity: EventTheme::class)] + private Collection $children; + + #[ORM\ManyToOne(targetEntity: EventTheme::class, inversedBy: 'children')] + private ?EventTheme $parent = null; + + #[ORM\Column(name: 'ordering', type: Types::FLOAT, options: ['default' => '0.0'])] + private float $ordering = 0.0; + + /** + * Constructor. + */ + public function __construct() + { + $this->children = new ArrayCollection(); + } + + /** + * Get active. + */ + public function getIsActive(): bool + { + return $this->isActive; + } + + /** + * Get id. + */ + public function getId(): ?int + { + return $this->id; + } + + /** + * Get label. + */ + public function getName(): array + { + return $this->name; + } + + public function setIsActive(bool $active): self + { + $this->isActive = $active; + + return $this; + } + + public function setName(array $label): self + { + $this->name = $label; + + return $this; + } + + public function addChild(self $child): self + { + if (!$this->children->contains($child)) { + $this->children[] = $child; + } + + return $this; + } + + public function removeChild(self $child): self + { + if ($this->children->removeElement($child)) { + // set the owning side to null (unless already changed) + if ($child->getParent() === $this) { + $child->setParent(null); + } + } + + return $this; + } + + public function getChildren(): Collection + { + return $this->children; + } + + public function hasChildren(): bool + { + return 0 < $this->getChildren()->count(); + } + + public function hasParent(): bool + { + return null !== $this->parent; + } + + public function getOrdering(): float + { + return $this->ordering; + } + + public function setOrdering(float $ordering): EventTheme + { + $this->ordering = $ordering; + + return $this; + } + + public function getParent(): ?self + { + return $this->parent; + } + + public function setParent(?self $parent): self + { + $this->parent = $parent; + + $parent?->addChild($this); + + return $this; + } +} diff --git a/src/Bundle/ChillEventBundle/Export/Export/ListEvents.php b/src/Bundle/ChillEventBundle/Export/Export/ListEvents.php new file mode 100644 index 000000000..7065e87c7 --- /dev/null +++ b/src/Bundle/ChillEventBundle/Export/Export/ListEvents.php @@ -0,0 +1,377 @@ +filterStatsByCenters = $parameterBag->get('chill_main')['acl']['filter_stats_by_center']; + } + + public function buildForm(FormBuilderInterface $builder): void + { + $builder + ->add('fields', ChoiceType::class, [ + 'multiple' => true, + 'expanded' => true, + 'choices' => array_combine($this->fields, $this->fields), + 'label' => 'Fields to include in export', + 'constraints' => [new Callback([ + 'callback' => static function ($selected, ExecutionContextInterface $context) { + if (0 === \count($selected)) { + $context->buildViolation('You must select at least one element') + ->atPath('fields') + ->addViolation(); + } + }, + ])], + ]); + + } + + public function getFormDefaultData(): array + { + return [ + 'fields' => $this->fields, + ]; + } + + public function getAllowedFormattersTypes(): array + { + return [FormatterInterface::TYPE_LIST]; + } + + public function getDescription(): string + { + return 'export.event.list.description'; + } + + public function getGroup(): string + { + return 'Exports of events'; + } + + public function getLabels($key, array $values, $data) + { + return match ($key) { + 'event_id' => fn ($value) => '_header' === $value ? $key : $value, + 'event_name' => fn ($value) => '_header' === $value ? $key : $value, + 'event_date' => function ($value) use ($key) { + if ('_header' === $value) { + return $key; + } + + if ($value instanceof \DateTime) { + return $value->format('Y-m-d'); + } + + $date = \DateTime::createFromFormat('Y-m-d H:i:s', $value); + + return $date ? $date->format('Y-m-d') : $value; + }, + 'event_type' => function ($value) use ($key) { + if ('_header' === $value) { + return 'export.event.list.'.$key; + } + + return $this->translatableStringHelper->localize(json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR)); + }, + 'event_center' => fn ($value) => '_header' === $value ? $key : $value, + 'event_moderator' => fn ($value) => '_header' === $value ? $key : $value, + 'event_participants_count' => fn ($value) => '_header' === $value ? $key : $value, + 'event_location' => fn ($value) => '_header' === $value ? $key : $value, + 'event_animators' => $this->labelThirdPartyHelper->getLabelMulti($key, $values, $key), + 'event_themes' => function ($value) use ($key) { + if ('_header' === $value) { + return $key; + } + + if (null === $value) { + return ''; + } + + return implode( + '|', + array_map( + fn ($t) => $this->eventThemeRender->renderString($this->eventThemeRepository->find($t), []), + json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR) + ) + ); + }, + 'event_budget_resources' => function ($value) use ($key) { + if ('_header' === $value) { + return $key; + } + + if (!$value) { + return ''; + } + + $ids = explode(',', $value); + $ids = json_decode($value, true, 512, JSON_THROW_ON_ERROR); + $elements = $this->eventBudgetElementRepository->findBy(['id' => $ids]); + + return implode('|', array_map(function ($element) { + $name = $this->translatableStringHelper->localize($element->getKind()->getName()); + $amount = number_format($element->getAmount(), 2, '.', ''); + + return $name.': '.$amount; + }, $elements)); + }, + + 'event_budget_charges' => function ($value) use ($key) { + if ('_header' === $value) { + return $key; + } + + if (!$value) { + return ''; + } + + $ids = explode(',', $value); + $ids = json_decode($value, true, 512, JSON_THROW_ON_ERROR); + + $elements = $this->eventBudgetElementRepository->findBy(['id' => $ids]); + + return implode('|', array_map(function ($element) { + $name = $this->translatableStringHelper->localize($element->getKind()->getName()); + $amount = number_format($element->getAmount(), 2, '.', ''); + + return $name.': '.$amount; + }, $elements)); + }, + + + default => fn ($value) => '_header' === $value ? $key : $value, + }; + } + + public function getQueryKeys(array $data): array + { + return $data['fields']; + } + + public function getResult($query, $data, ExportGenerationContext $context): array + { + return $query->getQuery()->getResult(Query::HYDRATE_SCALAR); + } + + public function getTitle(): string|TranslatableInterface + { + return 'export.event.list.title'; + } + + public function getType(): string + { + return Declarations::EVENT; + } + + public function initiateQuery(array $requiredModifiers, array $acl, array $data, ExportGenerationContext $context): NativeQuery|QueryBuilder + { + $centers = array_map(static fn ($el) => $el['center'], $acl); + + // Throw an error if no fields are present + if (!\array_key_exists('fields', $data)) { + throw new \InvalidArgumentException('No fields have been checked.'); + } + + $qb = $this->entityManager->createQueryBuilder() + ->from(Event::class, 'event'); + + if ($this->filterStatsByCenters) { + $qb + ->andWhere('event.center IN (:authorized_centers)') + ->setParameter('authorized_centers', $centers); + } + + // Add fields based on selection + foreach ($this->fields as $field) { + if (\in_array($field, $data['fields'], true)) { + switch ($field) { + case 'event_id': + $qb->addSelect('event.id AS event_id'); + break; + + case 'event_name': + $qb->addSelect('event.name AS event_name'); + break; + + case 'event_date': + $qb->addSelect('event.date AS event_date'); + break; + + case 'event_type': + if (!$this->hasJoin($qb, 'event.type')) { + $qb->leftJoin('event.type', 'type'); + } + $qb->addSelect('type.name AS event_type'); + break; + + case 'event_center': + if (!$this->hasJoin($qb, 'event.center')) { + $qb->leftJoin('event.center', 'center'); + } + $qb->addSelect('center.name AS event_center'); + break; + + case 'event_moderator': + if (!$this->hasJoin($qb, 'event.moderator')) { + $qb->leftJoin('event.moderator', 'user'); + } + $qb->addSelect('user.username AS event_moderator'); + break; + + case 'event_participants_count': + $qb->addSelect('(SELECT COUNT(p.id) FROM Chill\EventBundle\Entity\Participation p WHERE p.event = event.id) AS event_participants_count'); + break; + + case 'event_location': + if (!$this->hasJoin($qb, 'event.location')) { + $qb->leftJoin('event.location', 'location'); + } + $qb->addSelect('location.name AS event_location'); + break; + + case 'event_animators': + $qb->addSelect( + '(SELECT AGGREGATE(tp.id) FROM Chill\ThirdPartyBundle\Entity\ThirdParty tp WHERE tp MEMBER OF event.animators) AS event_animators' + ); + break; + + case 'event_themes': + $qb->addSelect( + '(SELECT AGGREGATE(t.id) FROM Chill\EventBundle\Entity\EventTheme t WHERE t MEMBER OF event.themes) AS event_themes' + ); + break; + + case 'event_budget_resources': + $qb->addSelect( + '(SELECT AGGREGATE(ebr.id) + FROM Chill\EventBundle\Entity\EventBudgetElement ebr + JOIN ebr.kind kr + WHERE ebr.event = event.id AND kr.type = :resource_type) AS event_budget_resources' + ); + $qb->setParameter('resource_type', BudgetTypeEnum::RESOURCE->value); + break; + + case 'event_budget_charges': + $qb->addSelect( + '(SELECT AGGREGATE(ebc.id) + FROM Chill\EventBundle\Entity\EventBudgetElement ebc + JOIN ebc.kind kc + WHERE ebc.event = event.id AND kc.type = :charge_type) AS event_budget_charges' + ); + $qb->setParameter('charge_type', BudgetTypeEnum::CHARGE->value); + break; + } + } + } + + return $qb; + } + + public function requiredRole(): string + { + return EventVoter::STATS; + } + + public function supportsModifiers() + { + return [Declarations::EVENT]; + } + + /** + * Helper method to check if a join already exists in the QueryBuilder. + */ + private function hasJoin($queryBuilder, $joinPath): bool + { + $joins = $queryBuilder->getDQLPart('join'); + if (!isset($joins['event'])) { + return false; + } + + foreach ($joins['event'] as $join) { + if ($join->getJoin() === $joinPath) { + return true; + } + } + + return false; + } + + public function normalizeFormData(array $formData): array + { + return ['fields' => $formData['fields']]; + } + + public function denormalizeFormData(array $formData, int $fromVersion): array + { + return ['fields' => $formData['fields']]; + } + + public function getNormalizationVersion(): int + { + return 1; + } +} diff --git a/src/Bundle/ChillEventBundle/Form/AddEventBudgetElementType.php b/src/Bundle/ChillEventBundle/Form/AddEventBudgetElementType.php new file mode 100644 index 000000000..a1942e4c1 --- /dev/null +++ b/src/Bundle/ChillEventBundle/Form/AddEventBudgetElementType.php @@ -0,0 +1,59 @@ +kindRepository->findByType(BudgetTypeEnum::CHARGE->value); + $resources = $this->kindRepository->findByType(BudgetTypeEnum::RESOURCE->value); + + $builder->add('kind', ChoiceType::class, [ + 'choices' => [ + 'event.budget.charges' => $charges, + 'event.budget.resources' => $resources, + ], + 'choice_label' => fn (EventBudgetKind $kind) => $this->translatableStringHelper->localize($kind->getName()), + 'choice_value' => fn (?EventBudgetKind $kind) => $kind?->getId(), + 'placeholder' => 'event.budget.Select a budget element kind', + ]) + ->add('amount', NumberType::class, [ + 'required' => true, + ]) + ->add('comment', CommentType::class, [ + 'label' => 'Comment', + 'required' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => EventBudgetElement::class, + ]); + } +} diff --git a/src/Bundle/ChillEventBundle/Form/EventBudgetKindType.php b/src/Bundle/ChillEventBundle/Form/EventBudgetKindType.php new file mode 100644 index 000000000..571c4f942 --- /dev/null +++ b/src/Bundle/ChillEventBundle/Form/EventBudgetKindType.php @@ -0,0 +1,53 @@ +add('name', TranslatableStringFormType::class, [ + 'label' => 'Title', + ]) + ->add('type', EnumType::class, [ + 'class' => BudgetTypeEnum::class, + 'choice_label' => fn (BudgetTypeEnum $type): string => $this->translator->trans($type->value), + 'expanded' => true, + 'multiple' => false, + 'mapped' => true, + 'label' => 'event.admin.Select budget type', + ]) + ->add('isActive', CheckboxType::class, [ + 'label' => 'Actif ?', + 'required' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver + ->setDefault('class', EventBudgetKind::class); + } +} diff --git a/src/Bundle/ChillEventBundle/Form/EventThemeType.php b/src/Bundle/ChillEventBundle/Form/EventThemeType.php new file mode 100644 index 000000000..351bf5a59 --- /dev/null +++ b/src/Bundle/ChillEventBundle/Form/EventThemeType.php @@ -0,0 +1,67 @@ +add('name', TranslatableStringFormType::class, [ + 'label' => 'Nom', + ]); + + if ('create' === $options['step']) { + $builder + ->add('parent', EntityType::class, [ + 'class' => EventTheme::class, + 'required' => false, + 'choice_label' => fn (EventTheme $theme): ?string => $this->translatableStringHelper->localize($theme->getName()), + 'mapped' => 'create' == $options['step'], + ]); + } + + $builder + ->add('ordering', NumberType::class, [ + 'required' => true, + 'scale' => 6, + ]) + ->add('isActive', ChoiceType::class, [ + 'choices' => [ + 'Yes' => true, + 'No' => false, + ], + 'expanded' => true, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => EventTheme::class, + ]); + $resolver->setRequired('step') + ->setAllowedValues('step', ['create', 'edit']); + } +} diff --git a/src/Bundle/ChillEventBundle/Form/EventType.php b/src/Bundle/ChillEventBundle/Form/EventType.php index ebdf66010..a4048983e 100644 --- a/src/Bundle/ChillEventBundle/Form/EventType.php +++ b/src/Bundle/ChillEventBundle/Form/EventType.php @@ -13,25 +13,39 @@ namespace Chill\EventBundle\Form; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Form\StoredObjectType; +use Chill\EventBundle\Entity\BudgetTypeEnum; use Chill\EventBundle\Entity\Event; +use Chill\EventBundle\Form\Type\PickEventThemeType; use Chill\EventBundle\Form\Type\PickEventTypeType; +use Chill\EventBundle\Repository\EventBudgetKindRepository; use Chill\MainBundle\Entity\Center; +use Chill\MainBundle\Form\DataTransformer\IdToLocationDataTransformer; use Chill\MainBundle\Form\Type\ChillCollectionType; use Chill\MainBundle\Form\Type\ChillDateTimeType; use Chill\MainBundle\Form\Type\CommentType; use Chill\MainBundle\Form\Type\PickUserDynamicType; -use Chill\MainBundle\Form\Type\PickUserLocationType; use Chill\MainBundle\Form\Type\ScopePickerType; +use Chill\ThirdPartyBundle\Form\Type\PickThirdpartyDynamicType; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\MoneyType; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Contracts\Translation\TranslatorInterface; class EventType extends AbstractType { - public function buildForm(FormBuilderInterface $builder, array $options) + public function __construct( + private readonly IdToLocationDataTransformer $idToLocationDataTransformer, + private readonly EventBudgetKindRepository $eventBudgetKindRepository, + private readonly TranslatorInterface $translator, + ) {} + + public function buildForm(FormBuilderInterface $builder, array $options): void { + $chargeKinds = $this->eventBudgetKindRepository->findByType(BudgetTypeEnum::CHARGE->value); + $resourceKinds = $this->eventBudgetKindRepository->findByType(BudgetTypeEnum::RESOURCE->value); + $builder ->add('name', TextType::class, [ 'required' => true, @@ -49,11 +63,28 @@ class EventType extends AbstractType 'class' => '', ], ]) + ->add('themes', PickEventThemeType::class, [ + 'multiple' => true, + ]) ->add('moderator', PickUserDynamicType::class, [ 'label' => 'Pick a moderator', ]) - ->add('location', PickUserLocationType::class, [ - 'label' => 'event.fields.location', + ->add('animatorsIntern', PickUserDynamicType::class, [ + 'multiple' => true, + 'label' => $this->translator->trans('event.fields.internal animators'), + 'required' => false, + ]) + ->add('animatorsExtern', PickThirdpartyDynamicType::class, [ + 'multiple' => true, + 'label' => $this->translator->trans('event.fields.external animators'), + 'required' => false, + ]) + ->add('budgetElements', ChillCollectionType::class, [ + 'entry_type' => AddEventBudgetElementType::class, + 'entry_options' => ['label' => false], + 'allow_add' => true, + 'allow_delete' => true, + 'by_reference' => false, ]) ->add('comment', CommentType::class, [ 'label' => 'Comment', @@ -69,11 +100,11 @@ class EventType extends AbstractType 'delete_empty' => fn (StoredObject $storedObject): bool => '' === $storedObject->getFilename(), 'button_remove_label' => 'event.form.remove_document', 'button_add_label' => 'event.form.add_document', - ]) - ->add('organizationCost', MoneyType::class, [ - 'label' => 'event.fields.organizationCost', - 'help' => 'event.form.organisationCost_help', ]); + + $builder->add('location', HiddenType::class) + ->get('location') + ->addModelTransformer($this->idToLocationDataTransformer); } public function configureOptions(OptionsResolver $resolver) @@ -87,11 +118,9 @@ class EventType extends AbstractType ->setAllowedTypes('role', 'string'); } - /** - * @return string - */ - public function getBlockPrefix() + public function getBlockPrefix(): string { - return 'chill_eventbundle_event'; + // as the js shares some hardcoded items from the activity bundle, we have to rewrite block prefix + return 'chill_activitybundle_activity'; } } diff --git a/src/Bundle/ChillEventBundle/Form/Type/PickEventThemeType.php b/src/Bundle/ChillEventBundle/Form/Type/PickEventThemeType.php new file mode 100644 index 000000000..3010df271 --- /dev/null +++ b/src/Bundle/ChillEventBundle/Form/Type/PickEventThemeType.php @@ -0,0 +1,45 @@ +setDefaults([ + 'class' => EventTheme::class, + 'choices' => $this->eventThemeRepository->findByActiveOrdered(), + 'choice_label' => fn (EventTheme $et) => $this->eventThemeRender->renderString($et, []), + 'placeholder' => 'event.form.Select one or more themes', + 'required' => true, + 'attr' => ['class' => 'select2'], + 'label' => 'event.theme.label', + 'multiple' => false, + ]) + ->setAllowedTypes('multiple', ['bool']); + } + + public function getParent(): string + { + return EntityType::class; + } +} diff --git a/src/Bundle/ChillEventBundle/Form/Type/PickEventTypeType.php b/src/Bundle/ChillEventBundle/Form/Type/PickEventTypeType.php index d027c05cf..7166893cc 100644 --- a/src/Bundle/ChillEventBundle/Form/Type/PickEventTypeType.php +++ b/src/Bundle/ChillEventBundle/Form/Type/PickEventTypeType.php @@ -23,15 +23,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; */ class PickEventTypeType extends AbstractType { - /** - * @var TranslatableStringHelper - */ - protected $translatableStringHelper; - - public function __construct(TranslatableStringHelper $helper) - { - $this->translatableStringHelper = $helper; - } + public function __construct(protected TranslatableStringHelper $translatableStringHelper) {} public function configureOptions(OptionsResolver $resolver) { diff --git a/src/Bundle/ChillEventBundle/Menu/AdminMenuBuilder.php b/src/Bundle/ChillEventBundle/Menu/AdminMenuBuilder.php index 9db1713b6..b7930fbba 100644 --- a/src/Bundle/ChillEventBundle/Menu/AdminMenuBuilder.php +++ b/src/Bundle/ChillEventBundle/Menu/AdminMenuBuilder.php @@ -17,17 +17,9 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; class AdminMenuBuilder implements LocalMenuBuilderInterface { - /** - * @var AuthorizationCheckerInterface - */ - protected $authorizationChecker; + public function __construct(protected AuthorizationCheckerInterface $authorizationChecker) {} - public function __construct(AuthorizationCheckerInterface $authorizationChecker) - { - $this->authorizationChecker = $authorizationChecker; - } - - public function buildMenu($menuId, MenuItem $menu, array $parameters) + public function buildMenu($menuId, MenuItem $menu, array $parameters): void { if (!$this->authorizationChecker->isGranted('ROLE_ADMIN')) { return; @@ -52,6 +44,14 @@ class AdminMenuBuilder implements LocalMenuBuilderInterface $menu->addChild('Role', [ 'route' => 'chill_event_admin_role', ])->setExtras(['order' => 6530]); + + $menu->addChild('event.theme.label', [ + 'route' => 'chill_crud_event_theme_index', + ])->setExtras(['order' => 6540]); + + $menu->addChild('event.budget.label', [ + 'route' => 'chill_crud_event_budget_kind_index', + ])->setExtras(['order' => 6550]); } public static function getMenuIds(): array diff --git a/src/Bundle/ChillEventBundle/Repository/EventACLAwareRepository.php b/src/Bundle/ChillEventBundle/Repository/EventACLAwareRepository.php index 22141b536..2b8c7c01e 100644 --- a/src/Bundle/ChillEventBundle/Repository/EventACLAwareRepository.php +++ b/src/Bundle/ChillEventBundle/Repository/EventACLAwareRepository.php @@ -88,6 +88,16 @@ final readonly class EventACLAwareRepository implements EventACLAwareRepositoryI $qb->andWhere('event.type IN (:event_types)'); $qb->setParameter('event_types', $filters['event_types']); } + + if (0 < count($filters['centers'] ?? [])) { + $qb->andWhere('event.center IN (:centers)'); + $qb->setParameter('centers', $filters['centers']); + } + + if (0 < count($filters['responsables'] ?? [])) { + $qb->andWhere('event.moderator IN (:responsables)'); + $qb->setParameter('responsables', $filters['responsables']); + } } public function buildQueryByAllViewable(array $filters): QueryBuilder diff --git a/src/Bundle/ChillEventBundle/Repository/EventBudgetElementRepository.php b/src/Bundle/ChillEventBundle/Repository/EventBudgetElementRepository.php new file mode 100644 index 000000000..48b871dc2 --- /dev/null +++ b/src/Bundle/ChillEventBundle/Repository/EventBudgetElementRepository.php @@ -0,0 +1,27 @@ + + */ +class EventBudgetElementRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, EventBudgetElement::class); + } +} diff --git a/src/Bundle/ChillEventBundle/Repository/EventBudgetKindRepository.php b/src/Bundle/ChillEventBundle/Repository/EventBudgetKindRepository.php new file mode 100644 index 000000000..0c40fce7d --- /dev/null +++ b/src/Bundle/ChillEventBundle/Repository/EventBudgetKindRepository.php @@ -0,0 +1,46 @@ + + */ +class EventBudgetKindRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, EventBudgetKind::class); + } + + public function findByActive(): array + { + return $this->createQueryBuilder('e') + ->select('e') + ->where('e.active = True') + ->getQuery() + ->getResult(); + } + + public function findByType(string $type): array + { + return $this->createQueryBuilder('e') + ->select('e') + ->where('e.type = :type') + ->setParameter('type', $type) + ->getQuery() + ->getResult(); + } +} diff --git a/src/Bundle/ChillEventBundle/Repository/EventThemeRepository.php b/src/Bundle/ChillEventBundle/Repository/EventThemeRepository.php new file mode 100644 index 000000000..add79faeb --- /dev/null +++ b/src/Bundle/ChillEventBundle/Repository/EventThemeRepository.php @@ -0,0 +1,37 @@ + + */ +class EventThemeRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, EventTheme::class); + } + + public function findByActiveOrdered(): array + { + return $this->createQueryBuilder('t') + ->select('t') + ->where('t.isActive = True') + ->orderBy('t.ordering', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Bundle/ChillEventBundle/Resources/public/chill/chillevent.scss b/src/Bundle/ChillEventBundle/Resources/public/chill/chillevent.scss index 6020d45dc..14f10b768 100644 --- a/src/Bundle/ChillEventBundle/Resources/public/chill/chillevent.scss +++ b/src/Bundle/ChillEventBundle/Resources/public/chill/chillevent.scss @@ -55,3 +55,13 @@ form#export_tableur { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out; } + +.participation-list { + flex-wrap: wrap; +} + +.participations-wrapper { + background-color: whitesmoke; + padding: 1rem; + margin-bottom: .5rem; +} diff --git a/src/Bundle/ChillEventBundle/Resources/public/vuejs/App.vue b/src/Bundle/ChillEventBundle/Resources/public/vuejs/App.vue new file mode 100644 index 000000000..89158d742 --- /dev/null +++ b/src/Bundle/ChillEventBundle/Resources/public/vuejs/App.vue @@ -0,0 +1,14 @@ + + + diff --git a/src/Bundle/ChillEventBundle/Resources/public/vuejs/index.js b/src/Bundle/ChillEventBundle/Resources/public/vuejs/index.js new file mode 100644 index 000000000..e0be75aaf --- /dev/null +++ b/src/Bundle/ChillEventBundle/Resources/public/vuejs/index.js @@ -0,0 +1,6 @@ +import { createApp } from "vue"; + +import App from "./App.vue"; +import store from "./store"; + +createApp(App).use(store).mount("#event"); diff --git a/src/Bundle/ChillEventBundle/Resources/public/vuejs/store.js b/src/Bundle/ChillEventBundle/Resources/public/vuejs/store.js new file mode 100644 index 000000000..4f4e194d8 --- /dev/null +++ b/src/Bundle/ChillEventBundle/Resources/public/vuejs/store.js @@ -0,0 +1,76 @@ +import "es6-promise/auto"; +import { createStore } from "vuex"; + +import prepareLocations from "ChillActivityAssets/vuejs/Activity/store.locations"; +import { whoami } from "ChillMainAssets/lib/api/user"; +import { postLocation } from "ChillActivityAssets/vuejs/Activity/api"; + +const debug = process.env.NODE_ENV !== "production"; + +const store = createStore({ + strict: debug, + state: { + activity: window.entity, // activity is the event entity in this case (re-using component from activity bundle) + currentEvent: null, + availableLocations: [], + me: null, + }, + getters: {}, + actions: { + addAvailableLocationGroup({ commit }, payload) { + commit("addAvailableLocationGroup", payload); + }, + updateLocation({ commit }, value) { + // console.log("### action: updateLocation", value); + let hiddenLocation = document.getElementById( + "chill_activitybundle_activity_location", + ); + if (value.onthefly) { + const body = { + type: "location", + name: + value.name === "__AccompanyingCourseLocation__" ? null : value.name, + locationType: { + id: value.locationType.id, + type: "location-type", + }, + }; + if (value.address.id) { + Object.assign(body, { + address: { + id: value.address.id, + }, + }); + } + postLocation(body) + .then((location) => (hiddenLocation.value = location.id)) + .catch((err) => { + console.log(err.message); + }); + } else { + hiddenLocation.value = value.id; + } + commit("updateLocation", value); + }, + }, + mutations: { + setWhoAmiI(state, me) { + state.me = me; + }, + addAvailableLocationGroup(state, group) { + state.availableLocations.push(group); + }, + updateLocation(state, value) { + // console.log("### mutation: updateLocation", value); + state.activity.location = value; + }, + }, +}); + +whoami().then((me) => { + store.commit("setWhoAmiI", me); +}); + +prepareLocations(store); + +export default store; diff --git a/src/Bundle/ChillEventBundle/Resources/views/Admin/BudgetKind/edit.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Admin/BudgetKind/edit.html.twig new file mode 100644 index 000000000..3a60d3e9c --- /dev/null +++ b/src/Bundle/ChillEventBundle/Resources/views/Admin/BudgetKind/edit.html.twig @@ -0,0 +1,12 @@ +{% extends '@ChillMain/CRUD/Admin/index.html.twig' %} + +{% block title %} + {% include('@ChillMain/CRUD/_edit_title.html.twig') %} +{% endblock %} + +{% block admin_content %} + {% embed '@ChillMain/CRUD/_edit_content.html.twig' %} + {% block content_form_actions_view %}{% endblock %} + {% block content_form_actions_save_and_show %}{% endblock %} + {% endembed %} +{% endblock %} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Admin/BudgetKind/index.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Admin/BudgetKind/index.html.twig new file mode 100644 index 000000000..f55ec4f17 --- /dev/null +++ b/src/Bundle/ChillEventBundle/Resources/views/Admin/BudgetKind/index.html.twig @@ -0,0 +1,51 @@ +{% extends '@ChillMain/Admin/layoutWithVerticalMenu.html.twig' %} + +{% block title %}{{ 'event.admin.title.Event budget element list'|trans }}{% endblock title %} + +{% block admin_content %} + +

{{ 'event.admin.title.Event budget element list'|trans }}

+ + + + + + + + + + + + {% for entity in entities %} + + + + + + + {% endfor %} + +
{{ 'Name'|trans }}{{ 'Type'|trans }}{{ 'Active'|trans }} 
{{ entity.name|localize_translatable_string }}{{ entity.type.value|trans }} + {%- if entity.isActive -%} + + {%- else -%} + + {%- endif -%} + +
    +
  • + +
  • +
+
+ + {{ chill_pagination(paginator) }} + + +{% endblock %} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Admin/BudgetKind/new.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Admin/BudgetKind/new.html.twig new file mode 100644 index 000000000..00ebd2938 --- /dev/null +++ b/src/Bundle/ChillEventBundle/Resources/views/Admin/BudgetKind/new.html.twig @@ -0,0 +1,11 @@ +{% extends '@ChillMain/CRUD/Admin/index.html.twig' %} + +{% block title %} + {% include('@ChillMain/CRUD/_new_title.html.twig') %} +{% endblock %} + +{% block admin_content %} + {% embed '@ChillMain/CRUD/_new_content.html.twig' %} + {% block content_form_actions_save_and_show %}{% endblock %} + {% endembed %} +{% endblock %} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Admin/EventTheme/edit.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Admin/EventTheme/edit.html.twig new file mode 100644 index 000000000..a63d81c99 --- /dev/null +++ b/src/Bundle/ChillEventBundle/Resources/views/Admin/EventTheme/edit.html.twig @@ -0,0 +1,26 @@ +{% extends '@ChillMain/CRUD/Admin/index.html.twig' %} + +{% block title %} + {% include('@ChillMain/CRUD/_edit_title.html.twig') %} +{% endblock %} + +{% block admin_content %} + {% embed '@ChillMain/CRUD/_edit_content.html.twig' %} + + {% block crud_content_form_rows %} + {{ form_row(form.name) }} +
+ +
+ {{ entity.parent|chill_entity_render_box }} +
+
+ {{ form_row(form.ordering) }} + {{ form_row(form.isActive) }} + {% endblock crud_content_form_rows %} + + {% block content_form_actions_save_and_show %}{% endblock %} + {% endembed %} +{% endblock admin_content %} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Admin/EventTheme/index.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Admin/EventTheme/index.html.twig new file mode 100644 index 000000000..d518ea625 --- /dev/null +++ b/src/Bundle/ChillEventBundle/Resources/views/Admin/EventTheme/index.html.twig @@ -0,0 +1,45 @@ +{% extends '@ChillMain/CRUD/Admin/index.html.twig' %} + +{% block admin_content %} + {% embed '@ChillMain/CRUD/_index.html.twig' %} + {% block table_entities_thead_tr %} + {{ 'Id'|trans }} + {{ 'Title'|trans }} + {{ 'Ordering'|trans }} + {{ 'active'|trans }} +   + {% endblock %} + + {% block table_entities_tbody %} + {% for entity in entities %} + + {{ entity.id }} + + {{ entity|chill_entity_render_box }} + + {{ entity.ordering }} + + {%- if entity.isActive -%} + + {%- else -%} + + {%- endif -%} + + +
    +
  • + +
  • +
+ + + {% endfor %} + {% endblock %} + + {% block actions_before %} +
  • + {{'Back to the admin'|trans}} +
  • + {% endblock %} + {% endembed %} +{% endblock %} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Admin/EventTheme/new.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Admin/EventTheme/new.html.twig new file mode 100644 index 000000000..7c204dddd --- /dev/null +++ b/src/Bundle/ChillEventBundle/Resources/views/Admin/EventTheme/new.html.twig @@ -0,0 +1,11 @@ +{% extends '@ChillMain/CRUD/Admin/index.html.twig' %} + +{% block title %} + {% include('@ChillMain/CRUD/_new_title.html.twig') %} +{% endblock %} + +{% block admin_content %} + {% embed '@ChillMain/CRUD/_new_content.html.twig' %} + {% block content_form_actions_save_and_show %}{% endblock %} + {% endembed %} +{% endblock admin_content %} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Entity/event_theme.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Entity/event_theme.html.twig new file mode 100644 index 000000000..08796d7b2 --- /dev/null +++ b/src/Bundle/ChillEventBundle/Resources/views/Entity/event_theme.html.twig @@ -0,0 +1,13 @@ +{% set reversed_parents = parents|reverse %} + + + {%- for p in reversed_parents %} + + {{ p.name|localize_translatable_string }}{{ options['default.separator'] }} + + {%- endfor -%} + + {{ eventTheme.name|localize_translatable_string }} + + + diff --git a/src/Bundle/ChillEventBundle/Resources/views/Event/confirm_delete.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Event/confirm_delete.html.twig index c3a13b55a..a7666f149 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Event/confirm_delete.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Event/confirm_delete.html.twig @@ -12,7 +12,7 @@ 'title' : 'Delete event'|trans, 'confirm_question' : 'Are you sure you want to remove that event ?'|trans, 'cancel_route' : activeRouteKey, - 'cancel_parameters' : { 'event_id' : event_id }, + 'cancel_parameters' : { 'id' : id }, 'form' : delete_form } ) }} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Event/edit.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Event/edit.html.twig index b6b11878b..55c2fc21f 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Event/edit.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Event/edit.html.twig @@ -17,10 +17,12 @@ {{ form_row(edit_form.date) }} {{ form_row(edit_form.type, { label: "Event type" }) }} + {{ form_row(edit_form.themes) }} {{ form_row(edit_form.moderator) }} + {{ form_row(edit_form.animatorsIntern) }} + {{ form_row(edit_form.animatorsExtern) }} {{ form_row(edit_form.location) }} - {{ form_row(edit_form.organizationCost) }} - + {{ form_row(edit_form.budgetElements) }} {{ form_row(edit_form.comment) }} {{ form_row(edit_form.documents) }} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Event/list.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Event/list.html.twig index 4bdb1f0f8..b376050b4 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Event/list.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Event/list.html.twig @@ -34,7 +34,7 @@
    • {# {% if is_granted('CHILL_EVENT_SEE_DETAILS', event) %} #} - + {{ 'See'|trans }} {# {% endif %} #} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Event/listByPerson.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Event/listByPerson.html.twig index 9096279b9..bfa089c0a 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Event/listByPerson.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Event/listByPerson.html.twig @@ -53,7 +53,7 @@ {% set returnLabel = 'Back to %person% events'|trans({ '%person%' : currentPerson } ) %} {% if is_granted('CHILL_EVENT_SEE_DETAILS', participation.event) %} - diff --git a/src/Bundle/ChillEventBundle/Resources/views/Event/new.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Event/new.html.twig index 0fb69a4ea..2f5a82c8d 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Event/new.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Event/new.html.twig @@ -1,25 +1,30 @@ -{% extends '@ChillEvent/layout.html.twig' %} {% block js %} -{{ encore_entry_script_tags("mod_async_upload") }} -{{ encore_entry_script_tags("mod_pickentity_type") }} +{% extends '@ChillEvent/layout.html.twig' %} -{% endblock %} {% block css %} +{% block css %} {{ encore_entry_link_tags("mod_async_upload") }} {{ encore_entry_link_tags("mod_pickentity_type") }} +{{ encore_entry_link_tags('vue_event') }} +{% endblock %} -{% endblock %} {% block title 'Event creation'|trans %} {% block event_content --%} +{% block title 'Event creation'|trans %} + +{% block event_content -%}

      {{ "Event creation" | trans }}

      {{ form_start(form) }} {{ form_errors(form) }} - {{ form_row(form.circle) }} {{ form_row(form.name) }} + {{ form_row(form.circle) }} {{ form_row(form.date) }} {{ form_row(form.type, { label: "Event type" }) }} + {{ form_row(form.themes) }} {{ form_row(form.moderator) }} + {{ form_row(form.animatorsIntern) }} + {{ form_row(form.animatorsExtern) }} {{ form_row(form.location) }} - {{ form_row(form.organizationCost) }} +
      + {{ form_row(form.budgetElements) }} {{ form_row(form.comment) }} {{ form_row(form.documents) }} @@ -40,5 +45,18 @@
    {{ form_end(form) }} + +
    + {% endblock %} + +{% block js %} + {{ encore_entry_script_tags("mod_async_upload") }} + {{ encore_entry_script_tags("mod_pickentity_type") }} + {{ encore_entry_script_tags('vue_event') }} + +{% endblock %} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Event/page_list.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Event/page_list.html.twig index bb1ffa24e..888c698e7 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Event/page_list.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Event/page_list.html.twig @@ -11,7 +11,7 @@ block js %} {{ filter | chill_render_filter_order_helper }} - {# {% if is_granted('CHILL_EVENT_CREATE') %} #} + {% if is_granted('CHILL_EVENT_CREATE') %} - {# {% endif %} #} {% if events|length > 0 %} + {% endif %} + + {% if events|length > 0 %}
    {% for e in events %}
    @@ -41,6 +43,12 @@ block js %} {{ e.moderator | chill_entity_render_box }}

    {% endif %} + +
    + {% for t in e.themes %} + {{ t|chill_entity_render_box }} + {% endfor %} +
    @@ -48,20 +56,21 @@ block js %}

    {{ 'count participations to this event'|trans({'count': e.participations|length}) }}

    +

    {{ "center"|trans }}: {{ e.center.name }}

    {% if e.participations|length > 0 %} -
    +
    {{ "Participations" | trans }} : - {% for part in e.participations|slice(0, 20) %} {% include + {% for part in e.participations|slice(0, 5) %} {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { targetEntity: { name: 'person', id: part.person.id }, action: 'show', displayBadge: true, buttonText: part.person|chill_entity_render_string, isDead: - part.person.deathdate is not null } %} {% endfor %} {% if - e.participations|length > 20 %} - {{ 'events.and_other_count_participants'|trans({'count': e.participations|length - 20}) }} + part.person.deathdate is not null } %} {% endfor %} + {% if e.participations|length > 5 %} + {{ 'events.and_other_count_participants'|trans({'count': e.participations|length - 5}) }} {% endif %}
    {% endif %} @@ -106,7 +115,7 @@ block js %} href="{{ chill_path_add_return_path( 'chill_event__event_show', - { event_id: e.id } + { id: e.id } ) }}" class="btn btn-show" diff --git a/src/Bundle/ChillEventBundle/Resources/views/Event/show.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Event/show.html.twig index 7d8bf1fc0..1f6f2d4b9 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Event/show.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Event/show.html.twig @@ -16,6 +16,16 @@ {{ encore_entry_link_tags('mod_document_action_buttons_group') }} {% endblock %} +{% macro insert_onthefly(type, entity, parent = null) %} + {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { + action: 'show', displayBadge: true, + targetEntity: { name: type, id: entity.id }, + buttonText: entity|chill_entity_render_string, + isDead: entity.deathdate is defined and entity.deathdate is not null, + parent: parent + } %} +{% endmacro %} + {% block event_content -%}

    {{ 'Details of an event'|trans }}

    @@ -26,6 +36,10 @@ {{ 'Circle'|trans }} {{ event.circle.name|localize_translatable_string }} + + {{ 'Center'|trans }} + {{ event.center.name }} + {{ 'Name'|trans }} {{ event.name }} @@ -39,12 +53,32 @@ {{ event.type.name|localize_translatable_string }} - {{ 'Moderator'|trans }} - {{ event.moderator|trans|default('-') }} + {{ 'event.theme.label'|trans }} + + {% for t in event.themes %} + {{ t|chill_entity_render_box }} + {% endfor %} + - {{ 'event.fields.organizationCost'|trans }} - {{ event.organizationCost|format_currency('EUR') }} + {{ 'Moderator'|trans }} + {{ event.moderator|chill_entity_render_string }} + + + {{ 'event.animators.intern'|trans }} + + {% for a in event.animatorsIntern %} + {{ _self.insert_onthefly('user', a) }} + {% endfor %} + + + + {{ 'event.animators.extern'|trans }} + + {% for a in event.animatorsExtern %} + {{ _self.insert_onthefly('thirdparty', a) }} + {% endfor %} + {{ 'event.fields.location'|trans }} @@ -60,6 +94,77 @@ +
    + {% set resources = event.budgetElements|filter(e => e.kind.type.value == 'Resource') %} + {% set charges = event.budgetElements|filter(e => e.kind.type.value == 'Charge') %} + +

    Budget de l'événement

    + +

    Ressources

    + {% if resources is not empty %} + + + + + + + + + + {% set totalResources = 0 %} + {% for res in resources %} + + + + + + {% set totalResources = totalResources + res.amount %} + {% endfor %} + + + + + + + +
    {{ 'event.budget.resource type'|trans }}{{ 'event.budget.amount'|trans }}{{ 'event.budget.comment'|trans }}
    {{ res.kind.name|localize_translatable_string }}{{ res.amount|format_currency('EUR') }}{{ res.comment.comment|chill_print_or_message(null, 'blockquote') }}
    Total{{ totalResources|format_currency('EUR') }}
    + {% else %} +

    {{ 'event.budget.no elements'|trans }}

    + {% endif %} + +

    Charges

    + {% if charges is not empty %} + + + + + + + + + + {% set totalCharges = 0 %} + {% for chg in charges %} + + + + + + {% set totalCharges = totalCharges + chg.amount %} + {% endfor %} + + + + + + + +
    {{ 'event.budget.charge type'|trans }}{{ 'event.budget.amount'|trans }}{{ 'event.budget.comment'|trans }}
    {{ chg.kind.name|localize_translatable_string }}{{ chg.amount|format_currency('EUR') }}{{ chg.comment.comment|chill_print_or_message(null, 'blockquote') }}
    Total{{ totalCharges|format_currency('EUR') }}
    + {% else %} +

    {{ 'event.budget.no elements'|trans }}

    + {% endif %} +
    + {% if event.documents|length > 0 %}

    {{ 'event.fields.documents'|trans }}

    @@ -80,6 +185,97 @@
    {% endif %} +
    +

    {{ 'Participations'|trans }}

    + {% set count = event.participations|length %} +

    {{ 'count participations to this event'|trans({'count': count}) }}

    + + {% if count > 0 %} + + + + + + + + + + + + {% for participation in event.participations %} + + + + + + + + {% endfor %} + +
    {{ 'Person'|trans }}{{ 'Role'|trans }}{{ 'Status'|trans }}{{ 'Last update'|trans }} 
    + {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { + targetEntity: { name: 'person', id: participation.person.id }, + action: 'show', + displayBadge: true, + buttonText: participation.person|chill_entity_render_string, + isDead: participation.person.deathdate is not null + } %} + {{ participation.role.name|localize_translatable_string }}{{ participation.status.name|localize_translatable_string }}{{ participation.lastUpdate|ago }} {# sf4 check: filter 'time_diff' is abandoned, + alternative: knplabs/knp-time-bundle provide filter 'ago' #} + + +
      + {% if is_granted('CHILL_EVENT_PARTICIPATION_UPDATE', participation) %} +
    • + +
    • +
    • + +
    • + {% endif %} +
    +
    + + + {% endif %} + +
    +
    + {{ form_start(form_add_participation_by_person) }} +
    + {{ form_widget(form_add_participation_by_person.person_id, { 'attr' : { + 'class' : 'custom-select', + 'style': 'min-width: 15em; max-width: 18em; display: inline-block;' + }} ) }} +
    + + {{ form_end(form_add_participation_by_person) }} +
    +
    + +
    +
    +
    + {{ chill_delegated_block('block_footer_show', { 'event': event }) }} +
      @@ -100,97 +296,5 @@
    - -

    {{ 'Participations'|trans }}

    - {% set count = event.participations|length %} -

    {{ 'count participations to this event'|trans({'count': count}) }}

    - - {% if count > 0 %} - - - - - - - - - - - - {% for participation in event.participations %} - - - - - - - - {% endfor %} - -
    {{ 'Person'|trans }}{{ 'Role'|trans }}{{ 'Status'|trans }}{{ 'Last update'|trans }} 
    - {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { - targetEntity: { name: 'person', id: participation.person.id }, - action: 'show', - displayBadge: true, - buttonText: participation.person|chill_entity_render_string, - isDead: participation.person.deathdate is not null - } %} - {{ participation.role.name|localize_translatable_string }}{{ participation.status.name|localize_translatable_string }}{{ participation.lastUpdate|ago }} {# sf4 check: filter 'time_diff' is abandoned, - alternative: knplabs/knp-time-bundle provide filter 'ago' #} - - -
      - {% if is_granted('CHILL_EVENT_PARTICIPATION_UPDATE', participation) %} -
    • - -
    • -
    • - -
    • - {% endif %} -
    -
    - - - {% endif %} - - - -
    -
    - {{ form_start(form_add_participation_by_person) }} -
    - {{ form_widget(form_add_participation_by_person.person_id, { 'attr' : { - 'class' : 'custom-select', - 'style': 'min-width: 15em; max-width: 18em; display: inline-block;' - }} ) }} -
    - - {{ form_end(form_add_participation_by_person) }} -
    - -
    - {{ form_start(form_export, {'attr': {'id': 'export_tableur'}}) }} -
    - {{ form_widget(form_export.format, { 'attr' : { 'class': 'custom-select' } }) }} -
    - {{ form_widget(form_export.submit, { 'attr' : { 'class': 'btn btn-save' } }) }} -
    - -
    - {{ form_rest(form_export) }} - {{ form_end(form_export) }} -
    -
    - -
    - {{ chill_delegated_block('block_footer_show', { 'event': event }) }} -
    {% endblock %} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Participation/confirm_delete.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Participation/confirm_delete.html.twig index 0d993b075..4d88caf59 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Participation/confirm_delete.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Participation/confirm_delete.html.twig @@ -11,7 +11,7 @@ 'title' : 'Remove participation'|trans, 'confirm_question' : 'Are you sure you want to remove that participation ?'|trans, 'cancel_route' : activeRouteKey, - 'cancel_parameters' : { 'event_id' : event_id }, + 'cancel_parameters' : { 'id' : event_id }, 'form' : delete_form } ) }} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Participation/edit.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Participation/edit.html.twig index d1995b625..ab6bbdb37 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Participation/edit.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Participation/edit.html.twig @@ -33,7 +33,7 @@ {% set returnPath = app.request.get('return_path') %} {% set returnLabel = app.request.get('return_label') %} - + {{ returnLabel |default('Back to the event'|trans) }} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Participation/new.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Participation/new.html.twig index 620bc7995..180b16c59 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Participation/new.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Participation/new.html.twig @@ -34,7 +34,7 @@
    • - + {{ 'Back to the event'|trans }}
    • diff --git a/src/Bundle/ChillEventBundle/Security/EventVoter.php b/src/Bundle/ChillEventBundle/Security/EventVoter.php index 29fcb6e9d..0c1f58bd2 100644 --- a/src/Bundle/ChillEventBundle/Security/EventVoter.php +++ b/src/Bundle/ChillEventBundle/Security/EventVoter.php @@ -54,9 +54,9 @@ class EventVoter extends AbstractChillVoter implements ProvideRoleHierarchyInter ) { $this->voterHelper = $voterHelperFactory ->generate(self::class) - ->addCheckFor(null, [self::SEE]) + ->addCheckFor(null, [self::SEE, self::CREATE]) ->addCheckFor(Event::class, [...self::ROLES]) - ->addCheckFor(Person::class, [self::SEE, self::CREATE]) + ->addCheckFor(Person::class, [self::SEE]) ->addCheckFor(Center::class, [self::STATS]) ->build(); } diff --git a/src/Bundle/ChillEventBundle/Templating/Entity/EventThemeRender.php b/src/Bundle/ChillEventBundle/Templating/Entity/EventThemeRender.php new file mode 100644 index 000000000..6a10b61f7 --- /dev/null +++ b/src/Bundle/ChillEventBundle/Templating/Entity/EventThemeRender.php @@ -0,0 +1,109 @@ + + */ +class EventThemeRender implements ChillEntityRenderInterface +{ + public const AND_CHILDREN_MENTION = 'show_and_children_mention'; + + public const DEFAULT_ARGS = [ + self::SEPARATOR_KEY => ' > ', + self::SHOW_AND_CHILDREN => false, + self::AND_CHILDREN_MENTION => 'event_theme.and children', + ]; + + public const SEPARATOR_KEY = 'default.separator'; + + /** + * Show a mention "and children" on each EventTheme, if the event theme + * has at least one child. + */ + public const SHOW_AND_CHILDREN = 'show_and_children'; + + public function __construct(private readonly TranslatableStringHelper $translatableStringHelper, private readonly \Twig\Environment $engine, private readonly TranslatorInterface $translator) {} + + /** + * @throws RuntimeError + * @throws SyntaxError + * @throws LoaderError + */ + public function renderBox($entity, array $options): string + { + $options = array_merge(self::DEFAULT_ARGS, $options); + // give some help to twig: an array of parents + $parents = $this->buildParents($entity); + + return $this + ->engine + ->render( + '@ChillEvent/Entity/event_theme.html.twig', + [ + 'eventTheme' => $entity, + 'parents' => $parents, + 'options' => $options, + ] + ); + } + + public function renderString($entity, array $options): string + { + /** @var EventTheme $entity */ + $options = array_merge(self::DEFAULT_ARGS, $options); + + $titles = [$this->translatableStringHelper->localize($entity->getName())]; + + // loop to parent, until root + while ($entity->hasParent()) { + $entity = $entity->getParent(); + $titles[] = $this->translatableStringHelper->localize( + $entity->getName() + ); + } + + $titles = \array_reverse($titles); + + $title = \implode($options[self::SEPARATOR_KEY], $titles); + + if ($options[self::SHOW_AND_CHILDREN] && $entity->hasChildren()) { + $title .= ' ('.$this->translator->trans($options[self::AND_CHILDREN_MENTION]).')'; + } + + return $title; + } + + public function supports($entity, array $options): bool + { + return $entity instanceof EventTheme; + } + + private function buildParents(EventTheme $entity): array + { + $parents = []; + + while ($entity->hasParent()) { + $entity = $parents[] = $entity->getParent(); + } + + return $parents; + } +} diff --git a/src/Bundle/ChillEventBundle/chill.webpack.config.js b/src/Bundle/ChillEventBundle/chill.webpack.config.js index e2c1e14bc..3f13a7773 100644 --- a/src/Bundle/ChillEventBundle/chill.webpack.config.js +++ b/src/Bundle/ChillEventBundle/chill.webpack.config.js @@ -1,3 +1,10 @@ module.exports = function (encore, entries) { entries.push(__dirname + "/Resources/public/chill/index.js"); + + encore.addEntry( + "vue_event", + __dirname + "/Resources/public/vuejs/index.js", + ); }; + + diff --git a/src/Bundle/ChillEventBundle/config/services.yaml b/src/Bundle/ChillEventBundle/config/services.yaml index cee12a024..b117528a8 100644 --- a/src/Bundle/ChillEventBundle/config/services.yaml +++ b/src/Bundle/ChillEventBundle/config/services.yaml @@ -1,6 +1,7 @@ services: Chill\EventBundle\Controller\: autowire: true + autoconfigure: true resource: '../Controller' tags: ['controller.service_arguments'] @@ -8,4 +9,11 @@ services: autowire: true autoconfigure: true resource: '../Menu/' - tags: ['chill.menu_builder'] \ No newline at end of file + tags: ['chill.menu_builder'] + + Chill\EventBundle\Templating\Entity\: + autowire: true + autoconfigure: true + resource: '../Templating/Entity' + tags: + - 'chill.render_entity' diff --git a/src/Bundle/ChillEventBundle/config/services/controller.yaml b/src/Bundle/ChillEventBundle/config/services/controller.yaml index e69de29bb..2dda648c9 100644 --- a/src/Bundle/ChillEventBundle/config/services/controller.yaml +++ b/src/Bundle/ChillEventBundle/config/services/controller.yaml @@ -0,0 +1,4 @@ +services: + _defaults: + autowire: true + autoconfigure: true diff --git a/src/Bundle/ChillEventBundle/config/services/export.yaml b/src/Bundle/ChillEventBundle/config/services/export.yaml index 8f8399e31..529c14893 100644 --- a/src/Bundle/ChillEventBundle/config/services/export.yaml +++ b/src/Bundle/ChillEventBundle/config/services/export.yaml @@ -8,6 +8,9 @@ services: Chill\EventBundle\Export\Export\CountEvents: tags: - { name: chill.export, alias: 'count_events' } + Chill\EventBundle\Export\Export\ListEvents: + tags: + - { name: chill.export, alias: 'list_events' } Chill\EventBundle\Export\Export\CountEventParticipations: tags: - { name: chill.export, alias: 'count_event_participants' } diff --git a/src/Bundle/ChillEventBundle/config/services/forms.yaml b/src/Bundle/ChillEventBundle/config/services/forms.yaml index 8c81c3e63..ab5d788f8 100644 --- a/src/Bundle/ChillEventBundle/config/services/forms.yaml +++ b/src/Bundle/ChillEventBundle/config/services/forms.yaml @@ -31,3 +31,29 @@ services: Chill\EventBundle\Form\Type\PickEventType: tags: - { name: form.type } + + Chill\EventBundle\Form\EventThemeType: + tags: + - { name: form.type } + + Chill\EventBundle\Form\Type\PickEventThemeType: + tags: + - { name: form.type } + + Chill\EventBundle\Form\EventType: + tags: + - { name: form.type } + + Chill\EventBundle\Form\EventBudgetKindType: + tags: + - { name: form.type } + + Chill\EventBundle\Form\AddEventBudgetElementType: + tags: + - { name: form.type } + + Chill\EventBundle\Form\Type\PickAnimatorType: + tags: + - { name: form.type } + + diff --git a/src/Bundle/ChillEventBundle/migrations/Version20250428092611.php b/src/Bundle/ChillEventBundle/migrations/Version20250428092611.php new file mode 100644 index 000000000..811a368ad --- /dev/null +++ b/src/Bundle/ChillEventBundle/migrations/Version20250428092611.php @@ -0,0 +1,40 @@ +addSql('CREATE SEQUENCE chill_event_event_theme_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE chill_event_event_theme (id INT NOT NULL, parent_id INT DEFAULT NULL, isActive BOOLEAN NOT NULL, name JSON NOT NULL, ordering DOUBLE PRECISION DEFAULT \'0.0\' NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_80D7C6B0727ACA70 ON chill_event_event_theme (parent_id)'); + $this->addSql('ALTER TABLE chill_event_event_theme ADD CONSTRAINT FK_80D7C6B0727ACA70 FOREIGN KEY (parent_id) REFERENCES chill_event_event_theme (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_event_event_theme DROP CONSTRAINT FK_80D7C6B0727ACA70'); + $this->addSql('DROP TABLE chill_event_event_theme'); + } +} diff --git a/src/Bundle/ChillEventBundle/migrations/Version20250429062911.php b/src/Bundle/ChillEventBundle/migrations/Version20250429062911.php new file mode 100644 index 000000000..967442601 --- /dev/null +++ b/src/Bundle/ChillEventBundle/migrations/Version20250429062911.php @@ -0,0 +1,42 @@ +addSql('CREATE TABLE chill_event_eventtheme (event_id INT NOT NULL, eventtheme_id INT NOT NULL, PRIMARY KEY(event_id, eventtheme_id))'); + $this->addSql('CREATE INDEX IDX_8D75029771F7E88B ON chill_event_eventtheme (event_id)'); + $this->addSql('CREATE INDEX IDX_8D750297A81D3C55 ON chill_event_eventtheme (eventtheme_id)'); + $this->addSql('ALTER TABLE chill_event_eventtheme ADD CONSTRAINT FK_8D75029771F7E88B FOREIGN KEY (event_id) REFERENCES chill_event_event (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_event_eventtheme ADD CONSTRAINT FK_8D750297A81D3C55 FOREIGN KEY (eventtheme_id) REFERENCES chill_event_event_theme (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_event_eventtheme DROP CONSTRAINT FK_8D75029771F7E88B'); + $this->addSql('ALTER TABLE chill_event_eventtheme DROP CONSTRAINT FK_8D750297A81D3C55'); + $this->addSql('DROP TABLE chill_event_eventtheme'); + } +} diff --git a/src/Bundle/ChillEventBundle/migrations/Version20250505120818.php b/src/Bundle/ChillEventBundle/migrations/Version20250505120818.php new file mode 100644 index 000000000..3c5f45651 --- /dev/null +++ b/src/Bundle/ChillEventBundle/migrations/Version20250505120818.php @@ -0,0 +1,43 @@ +addSql(<<<'SQL' + CREATE SEQUENCE chill_event_budget_kind_id_seq INCREMENT BY 1 MINVALUE 1 START 1 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE chill_event_budget_kind (id INT NOT NULL, isActive BOOLEAN DEFAULT true NOT NULL, type VARCHAR(255) NOT NULL, name JSONB DEFAULT '{}' NOT NULL, PRIMARY KEY(id)) + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + DROP SEQUENCE chill_event_budget_kind_id_seq CASCADE + SQL); + $this->addSql(<<<'SQL' + DROP TABLE chill_event_budget_kind + SQL); + } +} diff --git a/src/Bundle/ChillEventBundle/migrations/Version20250506114531.php b/src/Bundle/ChillEventBundle/migrations/Version20250506114531.php new file mode 100644 index 000000000..e349f50a7 --- /dev/null +++ b/src/Bundle/ChillEventBundle/migrations/Version20250506114531.php @@ -0,0 +1,61 @@ +addSql(<<<'SQL' + CREATE SEQUENCE chill_event_budget_element_id_seq INCREMENT BY 1 MINVALUE 1 START 1 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE chill_event_budget_element (id INT NOT NULL, event_id INT DEFAULT NULL, kind_id INT DEFAULT NULL, amount NUMERIC(10, 2) NOT NULL, comment_budget_element_comment TEXT DEFAULT NULL, comment_budget_element_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, comment_budget_element_userId INT DEFAULT NULL, PRIMARY KEY(id)) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_BA25859071F7E88B ON chill_event_budget_element (event_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_BA25859030602CA9 ON chill_event_budget_element (kind_id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_event_budget_element ADD CONSTRAINT FK_BA25859071F7E88B FOREIGN KEY (event_id) REFERENCES chill_event_event (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_event_budget_element ADD CONSTRAINT FK_BA25859030602CA9 FOREIGN KEY (kind_id) REFERENCES chill_event_budget_kind (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + DROP SEQUENCE chill_event_budget_element_id_seq CASCADE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_event_budget_element DROP CONSTRAINT FK_BA25859071F7E88B + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_event_budget_element DROP CONSTRAINT FK_BA25859030602CA9 + SQL); + $this->addSql(<<<'SQL' + DROP TABLE chill_event_budget_element + SQL); + } +} diff --git a/src/Bundle/ChillEventBundle/migrations/Version20250507073301.php b/src/Bundle/ChillEventBundle/migrations/Version20250507073301.php new file mode 100644 index 000000000..e5d7f0e52 --- /dev/null +++ b/src/Bundle/ChillEventBundle/migrations/Version20250507073301.php @@ -0,0 +1,55 @@ +addSql(<<<'SQL' + CREATE TABLE chill_event_thirdparty (event_id INT NOT NULL, thirdparty_id INT NOT NULL, PRIMARY KEY(event_id, thirdparty_id)) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_9946573E71F7E88B ON chill_event_thirdparty (event_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_9946573EC7D3A8E6 ON chill_event_thirdparty (thirdparty_id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_event_thirdparty ADD CONSTRAINT FK_9946573E71F7E88B FOREIGN KEY (event_id) REFERENCES chill_event_event (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_event_thirdparty ADD CONSTRAINT FK_9946573EC7D3A8E6 FOREIGN KEY (thirdparty_id) REFERENCES chill_3party.third_party (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE chill_event_thirdparty DROP CONSTRAINT FK_9946573E71F7E88B + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_event_thirdparty DROP CONSTRAINT FK_9946573EC7D3A8E6 + SQL); + $this->addSql(<<<'SQL' + DROP TABLE chill_event_thirdparty + SQL); + } +} diff --git a/src/Bundle/ChillEventBundle/migrations/Version20250702144312.php b/src/Bundle/ChillEventBundle/migrations/Version20250702144312.php new file mode 100644 index 000000000..afb072ae9 --- /dev/null +++ b/src/Bundle/ChillEventBundle/migrations/Version20250702144312.php @@ -0,0 +1,79 @@ +addSql(<<<'SQL' + CREATE TABLE chill_event_animatorsintern (event_id INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY(event_id, user_id)) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_E699558771F7E88B ON chill_event_animatorsintern (event_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_E6995587A76ED395 ON chill_event_animatorsintern (user_id) + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE chill_event_animatorsextern (event_id INT NOT NULL, thirdparty_id INT NOT NULL, PRIMARY KEY(event_id, thirdparty_id)) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_7EFBF7DE71F7E88B ON chill_event_animatorsextern (event_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_7EFBF7DEC7D3A8E6 ON chill_event_animatorsextern (thirdparty_id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_event_animatorsintern ADD CONSTRAINT FK_E699558771F7E88B FOREIGN KEY (event_id) REFERENCES chill_event_event (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_event_animatorsintern ADD CONSTRAINT FK_E6995587A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_event_animatorsextern ADD CONSTRAINT FK_7EFBF7DE71F7E88B FOREIGN KEY (event_id) REFERENCES chill_event_event (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_event_animatorsextern ADD CONSTRAINT FK_7EFBF7DEC7D3A8E6 FOREIGN KEY (thirdparty_id) REFERENCES chill_3party.third_party (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE chill_event_animatorsintern DROP CONSTRAINT FK_E699558771F7E88B + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_event_animatorsintern DROP CONSTRAINT FK_E6995587A76ED395 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_event_animatorsextern DROP CONSTRAINT FK_7EFBF7DE71F7E88B + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_event_animatorsextern DROP CONSTRAINT FK_7EFBF7DEC7D3A8E6 + SQL); + $this->addSql(<<<'SQL' + DROP TABLE chill_event_animatorsintern + SQL); + $this->addSql(<<<'SQL' + DROP TABLE chill_event_animatorsextern + SQL); + } +} diff --git a/src/Bundle/ChillEventBundle/translations/messages.fr.yml b/src/Bundle/ChillEventBundle/translations/messages.fr.yml index 6319a0765..2251dbd6c 100644 --- a/src/Bundle/ChillEventBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillEventBundle/translations/messages.fr.yml @@ -7,7 +7,8 @@ Participation: Participation Participations: Participations Status: Statut Last update: Dernière mise à jour -Moderator: Animateur +Moderator: Responsable +Animators: Animateurs #CRUD event Details of an event: Détails d'un événement @@ -74,7 +75,7 @@ Show the event: Voir l'événement Subscribe an event: Ajouter un événement Pick an event: Choisir un événement Pick a type of event: Choisir un type d'événement -Pick a moderator: Choisir un animateur +Pick a moderator: Choisir un responsable # exports Select a format: Choisir un format @@ -128,15 +129,70 @@ Create a new type: Créer un nouveau type Create a new status: Créer un nouveau statut event: + admin: + title: + Event budget element list: Liste des elements du budget pour un évenement + Select budget type: Selectionner le type d'element du budget + new: + Create a new budget kind: Créér un nouveau element de budget + theme: + label: Thématiques fields: organizationCost: Coût d'organisation location: Localisation documents: Documents + internal animators: Animateurs internes + external animators: Animateurs externes form: organisationCost_help: Coût d'organisation pour la structure. Utile pour les statistiques. add_document: Ajouter un document remove_document: Supprimer le document + Select one or more themes: Selectionnez une ou plusieurs thématiques filter: event_types: Par types d'événement event_dates: Par date d'événement + center: Par centre + by_responsable: Par responsable + pick_responsable: Filtrer par responsables + budget: + resources: Ressources + charges: Charges + label: Elements de budget d'un évenement + Select a budget element kind: Selectionner un element de budget + no elements: Il y a aucun element + resource type: Ressource + charge type: Charge + amount: Montant + comment: Commentaire + animators: + intern: Animateurs internes + extern: Animateurs externes +crud: + event_theme: + title_new: Créér une nouvelle thématique + title_edit: Modifier la thématique + index: + title: Liste des thématiques + add_new: Créér une nouvelle thématique + event_budget_kind: + title_new: Créér un nouveau element de budget + +export: + event: + list: + title: Liste des évenements + description: Crée la liste des évenements en fonction de différents paramètres. + +event_id: Identifiant +event_name: Nom +event_date: Date +event_type: Type d'évenement +event_center: Centre +event_moderator: Responsable +event_participants_count: Nombre de participants +event_location: Localisation +event_budget_resources: Ressources +event_budget_charges: Charges +event_animators: Animateurs +event_themes: Thématiques diff --git a/src/Bundle/ChillMainBundle/ChillMainBundle.php b/src/Bundle/ChillMainBundle/ChillMainBundle.php index 07d794ee5..4a059662c 100644 --- a/src/Bundle/ChillMainBundle/ChillMainBundle.php +++ b/src/Bundle/ChillMainBundle/ChillMainBundle.php @@ -20,6 +20,7 @@ use Chill\MainBundle\DependencyInjection\CompilerPass\SearchableServicesCompiler use Chill\MainBundle\DependencyInjection\CompilerPass\TimelineCompilerClass; use Chill\MainBundle\DependencyInjection\CompilerPass\WidgetsCompilerPass; use Chill\MainBundle\DependencyInjection\ConfigConsistencyCompilerPass; +use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface; use Chill\MainBundle\Notification\NotificationHandlerInterface; use Chill\MainBundle\Routing\LocalMenuBuilderInterface; use Chill\MainBundle\Search\SearchApiInterface; @@ -61,6 +62,8 @@ class ChillMainBundle extends Bundle ->addTag('chill_main.entity_info_provider'); $container->registerForAutoconfiguration(ProvideRoleInterface::class) ->addTag('chill_main.provide_role'); + $container->registerForAutoconfiguration(NotificationFlagProviderInterface::class) + ->addTag('chill_main.notification_flag_provider'); $container->addCompilerPass(new SearchableServicesCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); $container->addCompilerPass(new ConfigConsistencyCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); diff --git a/src/Bundle/ChillMainBundle/Controller/ExportController.php b/src/Bundle/ChillMainBundle/Controller/ExportController.php index 693c0e55e..da21b873a 100644 --- a/src/Bundle/ChillMainBundle/Controller/ExportController.php +++ b/src/Bundle/ChillMainBundle/Controller/ExportController.php @@ -345,7 +345,7 @@ class ExportController extends AbstractController * @param array $dataExport Raw data from export step * @param array $dataFormatter Raw data from formatter step */ - private function buildExportDataForNormalization(string $alias, ?array $dataCenters, array $dataExport, array $dataFormatter, ?SavedExport $savedExport): array + private function buildExportDataForNormalization(string $alias, ?array $dataCenters, array $dataExport, ?array $dataFormatter, ?SavedExport $savedExport): array { if ($this->filterStatsByCenters) { $formCenters = $this->createCreateFormExport($alias, 'generate_centers', [], null); @@ -365,7 +365,7 @@ class ExportController extends AbstractController $formExport->submit($dataExport); $dataExport = $formExport->getData(); - if (\count($dataFormatter) > 0) { + if (is_array($dataFormatter) && \count($dataFormatter) > 0) { $formFormatter = $this->createCreateFormExport( $alias, 'generate_formatter', @@ -381,7 +381,7 @@ class ExportController extends AbstractController 'export' => $dataExport['export']['export'] ?? [], 'filters' => $dataExport['export']['filters'] ?? [], 'aggregators' => $dataExport['export']['aggregators'] ?? [], - 'pick_formatter' => $dataExport['export']['pick_formatter']['alias'], + 'pick_formatter' => ($dataExport['export']['pick_formatter'] ?? [])['alias'] ?? '', 'formatter' => $dataFormatter['formatter'] ?? [], ]; } diff --git a/src/Bundle/ChillMainBundle/Controller/NotificationController.php b/src/Bundle/ChillMainBundle/Controller/NotificationController.php index 1580b1fa0..6113bd4a9 100644 --- a/src/Bundle/ChillMainBundle/Controller/NotificationController.php +++ b/src/Bundle/ChillMainBundle/Controller/NotificationController.php @@ -16,6 +16,7 @@ use Chill\MainBundle\Entity\NotificationComment; use Chill\MainBundle\Form\NotificationCommentType; use Chill\MainBundle\Form\NotificationType; use Chill\MainBundle\Notification\Exception\NotificationHandlerNotFound; +use Chill\MainBundle\Notification\FlagProviders\NotificationByUserFlagProvider; use Chill\MainBundle\Notification\NotificationHandlerManager; use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Repository\NotificationRepository; @@ -57,7 +58,8 @@ class NotificationController extends AbstractController $notification ->setRelatedEntityClass($request->query->get('entityClass')) ->setRelatedEntityId($request->query->getInt('entityId')) - ->setSender($this->security->getUser()); + ->setSender($this->security->getUser()) + ->setType(NotificationByUserFlagProvider::FLAG); $tos = $request->query->all('tos'); diff --git a/src/Bundle/ChillMainBundle/Controller/UserProfileController.php b/src/Bundle/ChillMainBundle/Controller/UserProfileController.php index a48d1a1e2..b022a2b60 100644 --- a/src/Bundle/ChillMainBundle/Controller/UserProfileController.php +++ b/src/Bundle/ChillMainBundle/Controller/UserProfileController.php @@ -11,14 +11,11 @@ declare(strict_types=1); namespace Chill\MainBundle\Controller; -use Chill\MainBundle\Form\UserPhonenumberType; +use Chill\MainBundle\Form\UserProfileType; use Chill\MainBundle\Security\ChillSecurity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\Form\Extension\Core\Type\SubmitType; -use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; -use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Component\Routing\Annotation\Route; @@ -41,16 +38,19 @@ final class UserProfileController extends AbstractController } $user = $this->security->getUser(); - $editForm = $this->createPhonenumberEditForm($user); + $editForm = $this->createForm(UserProfileType::class, $user); + + $editForm->get('notificationFlags')->setData($user->getNotificationFlags()); + $editForm->handleRequest($request); if ($editForm->isSubmitted() && $editForm->isValid()) { - $phonenumber = $editForm->get('phonenumber')->getData(); + $notificationFlagsData = $editForm->get('notificationFlags')->getData(); + $user->setNotificationFlags($notificationFlagsData); - $user->setPhonenumber($phonenumber); - - $this->managerRegistry->getManager()->flush(); - $this->addFlash('success', $this->translator->trans('user.profile.Phonenumber successfully updated!')); + $em = $this->managerRegistry->getManager(); + $em->flush(); + $this->addFlash('success', $this->translator->trans('user.profile.Profile successfully updated!')); return $this->redirectToRoute('chill_main_user_profile'); } @@ -60,13 +60,4 @@ final class UserProfileController extends AbstractController 'form' => $editForm->createView(), ]); } - - private function createPhonenumberEditForm(UserInterface $user): FormInterface - { - return $this->createForm( - UserPhonenumberType::class, - $user, - ) - ->add('submit', SubmitType::class, ['label' => $this->translator->trans('Save')]); - } } diff --git a/src/Bundle/ChillMainBundle/Entity/Notification.php b/src/Bundle/ChillMainBundle/Entity/Notification.php index 9f08e0487..20773b884 100644 --- a/src/Bundle/ChillMainBundle/Entity/Notification.php +++ b/src/Bundle/ChillMainBundle/Entity/Notification.php @@ -14,6 +14,7 @@ namespace Chill\MainBundle\Entity; use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -21,10 +22,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ORM\Entity] #[ORM\HasLifecycleCallbacks] #[ORM\Table(name: 'chill_main_notification')] -#[ORM\Index(name: 'chill_main_notification_related_entity_idx', columns: ['relatedentityclass', 'relatedentityid'])] +#[ORM\Index(columns: ['relatedentityclass', 'relatedentityid'], name: 'chill_main_notification_related_entity_idx')] class Notification implements TrackUpdateInterface { - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false)] + #[ORM\Column(type: Types::TEXT, nullable: false)] private string $accessKey; private array $addedAddresses = []; @@ -36,12 +37,19 @@ class Notification implements TrackUpdateInterface #[ORM\JoinTable(name: 'chill_main_notification_addresses_user')] private Collection $addressees; + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: UserGroup::class)] + #[ORM\JoinTable(name: 'chill_main_notification_addressee_user_group')] + private Collection $addresseeUserGroups; + /** * a list of destinee which will receive notifications. * * @var array|string[] */ - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, options: ['default' => '[]', 'jsonb' => true])] + #[ORM\Column(type: Types::JSON, options: ['default' => '[]', 'jsonb' => true])] private array $addressesEmails = []; /** @@ -60,21 +68,21 @@ class Notification implements TrackUpdateInterface #[ORM\OrderBy(['createdAt' => \Doctrine\Common\Collections\Criteria::ASC])] private Collection $comments; - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE)] + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] private \DateTimeImmutable $date; #[ORM\Id] #[ORM\GeneratedValue] - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)] + #[ORM\Column(type: Types::INTEGER)] private ?int $id = null; - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)] + #[ORM\Column(type: Types::TEXT)] private string $message = ''; - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255)] + #[ORM\Column(type: Types::STRING, length: 255)] private string $relatedEntityClass = ''; - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)] + #[ORM\Column(type: Types::INTEGER)] private int $relatedEntityId; private array $removedAddresses = []; @@ -84,7 +92,7 @@ class Notification implements TrackUpdateInterface private ?User $sender = null; #[Assert\NotBlank(message: 'notification.Title must be defined')] - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => ''])] + #[ORM\Column(type: Types::TEXT, options: ['default' => ''])] private string $title = ''; /** @@ -94,31 +102,46 @@ class Notification implements TrackUpdateInterface #[ORM\JoinTable(name: 'chill_main_notification_addresses_unread')] private Collection $unreadBy; - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE)] + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] private ?\DateTimeImmutable $updatedAt = null; #[ORM\ManyToOne(targetEntity: User::class)] private ?User $updatedBy = null; + #[ORM\Column(name: 'type', type: Types::STRING, nullable: true)] + private string $type = ''; + public function __construct() { $this->addressees = new ArrayCollection(); + $this->addresseeUserGroups = new ArrayCollection(); $this->unreadBy = new ArrayCollection(); $this->comments = new ArrayCollection(); $this->setDate(new \DateTimeImmutable()); $this->accessKey = bin2hex(openssl_random_pseudo_bytes(24)); } - public function addAddressee(User $addressee): self + public function addAddressee(User|UserGroup $addressee): self { - if (!$this->addressees->contains($addressee)) { - $this->addressees[] = $addressee; - $this->addedAddresses[] = $addressee; + if ($addressee instanceof User) { + if (!$this->addressees->contains($addressee)) { + $this->addressees->add($addressee); + $this->addedAddresses[] = $addressee; + } + + return $this; + } + + if (!$this->addresseeUserGroups->contains($addressee)) { + $this->addresseeUserGroups->add($addressee); } return $this; } + /** + * @deprecated + */ public function addAddressesEmail(string $email) { if (!\in_array($email, $this->addressesEmails, true)) { @@ -152,13 +175,23 @@ class Notification implements TrackUpdateInterface #[Assert\Callback] public function assertCountAddresses(ExecutionContextInterface $context, $payload): void { - if (0 === (\count($this->getAddressesEmails()) + \count($this->getAddressees()))) { + if (0 === (\count($this->getAddresseeUserGroups()) + \count($this->getAddressees()))) { $context->buildViolation('notification.At least one addressee') ->atPath('addressees') ->addViolation(); } } + public function getAddresseeUserGroups(): Collection + { + return $this->addresseeUserGroups; + } + + public function setAddresseeUserGroups(Collection $addresseeUserGroups): void + { + $this->addresseeUserGroups = $addresseeUserGroups; + } + public function getAccessKey(): string { return $this->accessKey; @@ -182,6 +215,23 @@ class Notification implements TrackUpdateInterface return $this->addressees; } + public function getAllAddressees(): array + { + $allUsers = []; + + foreach ($this->getAddressees() as $user) { + $allUsers[$user->getId()] = $user; + } + + foreach ($this->getAddresseeUserGroups() as $userGroup) { + foreach ($userGroup->getUsers() as $user) { + $allUsers[$user->getId()] = $user; + } + } + + return array_values($allUsers); + } + /** * @return array|string[] */ @@ -303,12 +353,18 @@ class Notification implements TrackUpdateInterface $this->addressesOnLoad = null; } - public function removeAddressee(User $addressee): self + public function removeAddressee(User|UserGroup $addressee): self { - if ($this->addressees->removeElement($addressee)) { - $this->removedAddresses[] = $addressee; + if ($addressee instanceof User) { + if ($this->addressees->contains($addressee)) { + $this->addressees->removeElement($addressee); + + return $this; + } } + $this->addresseeUserGroups->removeElement($addressee); + return $this; } @@ -378,7 +434,7 @@ class Notification implements TrackUpdateInterface public function setUpdatedAt(\DateTimeInterface $datetime): self { - $this->updatedAt = $datetime; + $this->updatedAt = \DateTimeImmutable::createFromInterface($datetime); return $this; } @@ -389,4 +445,16 @@ class Notification implements TrackUpdateInterface return $this; } + + public function setType(string $type): self + { + $this->type = $type; + + return $this; + } + + public function getType(): string + { + return $this->type; + } } diff --git a/src/Bundle/ChillMainBundle/Entity/User.php b/src/Bundle/ChillMainBundle/Entity/User.php index 3325a9c83..61263ef85 100644 --- a/src/Bundle/ChillMainBundle/Entity/User.php +++ b/src/Bundle/ChillMainBundle/Entity/User.php @@ -34,6 +34,9 @@ use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; #[ORM\Table(name: 'users')] class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInterface { + public const NOTIF_FLAG_IMMEDIATE_EMAIL = 'immediate-email'; + public const NOTIF_FLAG_DAILY_DIGEST = 'daily-digest'; + #[ORM\Id] #[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)] #[ORM\GeneratedValue(strategy: 'AUTO')] @@ -116,6 +119,12 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter #[PhonenumberConstraint] private ?PhoneNumber $phonenumber = null; + /** + * @var array> + */ + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])] + private array $notificationFlags = []; + /** * User constructor. */ @@ -623,4 +632,47 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter { return true; } + + public function getNotificationFlags(): array + { + return $this->notificationFlags; + } + + public function setNotificationFlags(array $notificationFlags) + { + $this->notificationFlags = $notificationFlags; + } + + public function getNotificationFlagData(string $flag): array + { + return $this->notificationFlags[$flag] ?? []; + } + + public function setNotificationFlagData(string $flag, array $data): void + { + $this->notificationFlags[$flag] = $data; + } + + public function isNotificationSendImmediately(string $type): bool + { + if ([] === $this->getNotificationFlagData($type) || in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $this->getNotificationFlagData($type), true)) { + return true; + } + + return false; + } + + public function isNotificationDailyDigest(string $type): bool + { + if (in_array(User::NOTIF_FLAG_DAILY_DIGEST, $this->getNotificationFlagData($type), true)) { + return true; + } + + return false; + } + + public function getLocale(): string + { + return 'fr'; + } } diff --git a/src/Bundle/ChillMainBundle/Export/ExportConfigNormalizer.php b/src/Bundle/ChillMainBundle/Export/ExportConfigNormalizer.php index c94e7ba03..b71b2d102 100644 --- a/src/Bundle/ChillMainBundle/Export/ExportConfigNormalizer.php +++ b/src/Bundle/ChillMainBundle/Export/ExportConfigNormalizer.php @@ -72,10 +72,14 @@ class ExportConfigNormalizer } $serialized['aggregators'] = $aggregatorsSerialized; - $serialized['pick_formatter'] = $formData['pick_formatter']; - $formatter = $this->exportManager->getFormatter($formData['pick_formatter']); - $serialized['formatter']['form'] = $formatter->normalizeFormData($formData['formatter']); - $serialized['formatter']['version'] = $formatter->getNormalizationVersion(); + if ($export instanceof ExportInterface) { + $serialized['pick_formatter'] = $formData['pick_formatter']; + $formatter = $this->exportManager->getFormatter($formData['pick_formatter']); + $serialized['formatter']['form'] = $formatter->normalizeFormData($formData['formatter']); + $serialized['formatter']['version'] = $formatter->getNormalizationVersion(); + } elseif ($export instanceof DirectExportInterface) { + $serialized['formatter'] = ['form' => [], 'version' => 0]; + } return $serialized; } @@ -87,7 +91,12 @@ class ExportConfigNormalizer public function denormalizeConfig(string $exportAlias, array $serializedData, bool $replaceDisabledByDefaultData = false): array { $export = $this->exportManager->getExport($exportAlias); - $formater = $this->exportManager->getFormatter($serializedData['pick_formatter']); + + if ($export instanceof ExportInterface) { + $formatter = $this->exportManager->getFormatter($serializedData['pick_formatter']); + } else { + $formatter = null; + } $filtersConfig = []; foreach ($serializedData['filters'] as $alias => $filterData) { @@ -117,8 +126,8 @@ class ExportConfigNormalizer 'export' => $export->denormalizeFormData($serializedData['export']['form'], $serializedData['export']['version']), 'filters' => $filtersConfig, 'aggregators' => $aggregatorsConfig, - 'pick_formatter' => $serializedData['pick_formatter'], - 'formatter' => $formater->denormalizeFormData($serializedData['formatter']['form'], $serializedData['formatter']['version']), + 'pick_formatter' => $serializedData['pick_formatter'] ?? '', + 'formatter' => $formatter?->denormalizeFormData($serializedData['formatter']['form'], $serializedData['formatter']['version']), 'centers' => [ 'centers' => array_values(array_filter(array_map(fn (int $id) => $this->centerRepository->find($id), $serializedData['centers']['centers']), fn ($item) => null !== $item)), 'regroupments' => array_values(array_filter(array_map(fn (int $id) => $this->regroupmentRepository->find($id), $serializedData['centers']['regroupments']), fn ($item) => null !== $item)), diff --git a/src/Bundle/ChillMainBundle/Form/DataMapper/NotificationFlagDataMapper.php b/src/Bundle/ChillMainBundle/Form/DataMapper/NotificationFlagDataMapper.php new file mode 100644 index 000000000..d904ed5b5 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/DataMapper/NotificationFlagDataMapper.php @@ -0,0 +1,75 @@ +notificationFlagProviders as $flagProvider) { + $flag = $flagProvider->getFlag(); + + if (isset($formsArray[$flag])) { + $flagForm = $formsArray[$flag]; + + $immediateEmailChecked = in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $viewData[$flag] ?? [], true) + || !array_key_exists($flag, $viewData); + $dailyEmailChecked = in_array(User::NOTIF_FLAG_DAILY_DIGEST, $viewData[$flag] ?? [], true); + + if ($flagForm->has('immediate_email')) { + $flagForm->get('immediate_email')->setData($immediateEmailChecked); + } + if ($flagForm->has('daily_email')) { + $flagForm->get('daily_email')->setData($dailyEmailChecked); + } + } + } + } + + public function mapFormsToData($forms, &$viewData): void + { + $formsArray = iterator_to_array($forms); + $viewData = []; + + foreach ($this->notificationFlagProviders as $flagProvider) { + $flag = $flagProvider->getFlag(); + + if (isset($formsArray[$flag])) { + $flagForm = $formsArray[$flag]; + $viewData[$flag] = []; + + if (true === $flagForm['immediate_email']->getData()) { + $viewData[$flag][] = User::NOTIF_FLAG_IMMEDIATE_EMAIL; + } + + if (true === $flagForm['daily_email']->getData()) { + $viewData[$flag][] = User::NOTIF_FLAG_DAILY_DIGEST; + } + + if ([] === $viewData[$flag]) { + $viewData[$flag][] = User::NOTIF_FLAG_IMMEDIATE_EMAIL; + } + } + } + } +} diff --git a/src/Bundle/ChillMainBundle/Form/NotificationType.php b/src/Bundle/ChillMainBundle/Form/NotificationType.php index 2bd8ba820..58fb1925d 100644 --- a/src/Bundle/ChillMainBundle/Form/NotificationType.php +++ b/src/Bundle/ChillMainBundle/Form/NotificationType.php @@ -12,17 +12,12 @@ declare(strict_types=1); namespace Chill\MainBundle\Form; use Chill\MainBundle\Entity\Notification; -use Chill\MainBundle\Form\Type\ChillCollectionType; use Chill\MainBundle\Form\Type\ChillTextareaType; -use Chill\MainBundle\Form\Type\PickUserDynamicType; +use Chill\MainBundle\Form\Type\PickUserGroupOrUserDynamicType; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; -use Symfony\Component\Validator\Constraints\Email; -use Symfony\Component\Validator\Constraints\NotBlank; -use Symfony\Component\Validator\Constraints\NotNull; class NotificationType extends AbstractType { @@ -33,29 +28,14 @@ class NotificationType extends AbstractType 'label' => 'Title', 'required' => true, ]) - ->add('addressees', PickUserDynamicType::class, [ + ->add('addressees', PickUserGroupOrUserDynamicType::class, [ 'multiple' => true, - 'required' => false, + 'label' => 'notification.Pick user or user group', + 'empty_data' => '[]', + 'required' => true, ]) ->add('message', ChillTextareaType::class, [ 'required' => false, - ]) - ->add('addressesEmails', ChillCollectionType::class, [ - 'label' => 'notification.dest by email', - 'help' => 'notification.dest by email help', - 'by_reference' => false, - 'allow_add' => true, - 'allow_delete' => true, - 'entry_type' => EmailType::class, - 'button_add_label' => 'notification.Add an email', - 'button_remove_label' => 'notification.Remove an email', - 'empty_collection_explain' => 'notification.Any email', - 'entry_options' => [ - 'constraints' => [ - new NotNull(), new NotBlank(), new Email(), - ], - 'label' => 'Email', - ], ]); } diff --git a/src/Bundle/ChillMainBundle/Form/Type/DateIntervalType.php b/src/Bundle/ChillMainBundle/Form/Type/DateIntervalType.php index c9bc4dd82..b878ffb66 100644 --- a/src/Bundle/ChillMainBundle/Form/Type/DateIntervalType.php +++ b/src/Bundle/ChillMainBundle/Form/Type/DateIntervalType.php @@ -55,6 +55,10 @@ class DateIntervalType extends AbstractType { $builder ->add('n', IntegerType::class, [ + 'attr' => [ + 'min' => 0, + 'step' => 1, + ], 'constraints' => [ new GreaterThan([ 'value' => 0, diff --git a/src/Bundle/ChillMainBundle/Form/Type/NotificationFlagsType.php b/src/Bundle/ChillMainBundle/Form/Type/NotificationFlagsType.php new file mode 100644 index 000000000..4535a4815 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/Type/NotificationFlagsType.php @@ -0,0 +1,63 @@ +notificationFlagProviders = $notificationFlagManager->getAllNotificationFlagProviders(); + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->setDataMapper(new NotificationFlagDataMapper($this->notificationFlagProviders)); + + foreach ($this->notificationFlagProviders as $flagProvider) { + $flag = $flagProvider->getFlag(); + $builder->add($flag, FormType::class, [ + 'label' => $flagProvider->getLabel(), + 'required' => false, + ]); + + $builder->get($flag) + ->add('immediate_email', CheckboxType::class, [ + 'label' => false, + 'required' => false, + 'mapped' => false, + ]) + ->add('daily_email', CheckboxType::class, [ + 'label' => false, + 'required' => false, + 'mapped' => false, + ]) + ; + } + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => null, + ]); + } +} diff --git a/src/Bundle/ChillMainBundle/Form/UserProfileType.php b/src/Bundle/ChillMainBundle/Form/UserProfileType.php new file mode 100644 index 000000000..f9fa65991 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/UserProfileType.php @@ -0,0 +1,41 @@ +add('phonenumber', ChillPhoneNumberType::class, [ + 'required' => false, + ]) + ->add('notificationFlags', NotificationFlagsType::class, [ + 'label' => false, + 'mapped' => false, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => \Chill\MainBundle\Entity\User::class, + ]); + } +} diff --git a/src/Bundle/ChillMainBundle/Notification/Email/DailyNotificationDigestCronjob.php b/src/Bundle/ChillMainBundle/Notification/Email/DailyNotificationDigestCronjob.php new file mode 100644 index 000000000..5ed6696f7 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Notification/Email/DailyNotificationDigestCronjob.php @@ -0,0 +1,102 @@ +clock->now(); + + if (null !== $cronJobExecution && $now->sub(new \DateInterval('PT23H45M')) < $cronJobExecution->getLastStart()) { + return false; + } + + // Run between 6 and 9 AM + return in_array((int) $now->format('H'), [6, 7, 8], true); + } + + public function getKey(): string + { + return 'daily-notification-digest'; + } + + /** + * @throws \DateInvalidOperationException + * @throws Exception + */ + public function run(array $lastExecutionData): ?array + { + $now = $this->clock->now(); + if (isset($lastExecutionData['last_execution'])) { + $lastExecution = \DateTimeImmutable::createFromFormat( + \DateTimeImmutable::ATOM, + $lastExecutionData['last_execution'] + ); + } else { + $lastExecution = $now->sub(new \DateInterval('P1D')); + } + + // Get distinct users who received notifications since the last execution + $sql = <<<'SQL' + SELECT DISTINCT cmnau.user_id + FROM chill_main_notification cmn + JOIN chill_main_notification_addresses_user cmnau ON cmnau.notification_id = cmn.id + WHERE cmn.date >= :lastExecution AND cmn.date <= :now + SQL; + + $sqlStatement = $this->connection->prepare($sql); + $sqlStatement->bindValue('lastExecution', $lastExecution->format(\DateTimeInterface::RFC3339)); + $sqlStatement->bindValue('now', $now->format(\DateTimeInterface::RFC3339)); + $result = $sqlStatement->executeQuery(); + + $count = 0; + foreach ($result->fetchAllAssociative() as $row) { + $userId = (int) $row['user_id']; + + $message = new ScheduleDailyNotificationDigestMessage( + $userId, + $lastExecution, + $now + ); + + $this->messageBus->dispatch($message); + ++$count; + } + + $this->logger->info('[DailyNotificationDigestCronjob] Dispatched daily digest messages', [ + 'user_count' => $count, + 'last_execution' => $lastExecution->format('Y-m-d-H:i:s.u e'), + 'current_time' => $now->format('Y-m-d-H:i:s.u e'), + ]); + + return [ + 'last_execution' => $now->format('Y-m-d-H:i:s.u e'), + ]; + } +} diff --git a/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailHandlers/ScheduleDailyNotificationDigestHandler.php b/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailHandlers/ScheduleDailyNotificationDigestHandler.php new file mode 100644 index 000000000..0a6aef393 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailHandlers/ScheduleDailyNotificationDigestHandler.php @@ -0,0 +1,75 @@ +getUserId(); + $lastExecutionDate = $message->getLastExecutionDateTime(); + $currentDate = $message->getCurrentDateTime(); + + $user = $this->userRepository->find($userId); + if (null === $user) { + $this->logger->warning('[ScheduleDailyNotificationDigestHandler] User not found', [ + 'user_id' => $userId, + ]); + + throw new \InvalidArgumentException(sprintf('User with ID %s not found', $userId)); + } + + // Get all notifications for this user between last execution and current date + $notifications = $this->notificationRepository->findNotificationsForUserBetweenDates( + $userId, + $lastExecutionDate, + $currentDate + ); + + // Filter out notifications that should be sent in a daily digest + $dailyNotifications = array_filter($notifications, fn ($notification) => $user->isNotificationDailyDigest($notification->getType())); + + if ([] === $dailyNotifications) { + $this->logger->info('[ScheduleDailyNotificationDigestHandler] No daily notifications found for user', [ + 'user_id' => $userId, + ]); + + return; + } + + $this->notificationMailer->sendDailyDigest($user, $dailyNotifications); + + $this->logger->info('[ScheduleDailyNotificationDigestHandler] Sent daily digest', [ + 'user_id' => $userId, + 'notification_count' => count($dailyNotifications), + ]); + } +} diff --git a/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailHandlers/SendImmediateNotificationEmailHandler.php b/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailHandlers/SendImmediateNotificationEmailHandler.php new file mode 100644 index 000000000..b27f16423 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailHandlers/SendImmediateNotificationEmailHandler.php @@ -0,0 +1,68 @@ +notificationRepository->find($message->getNotificationId()); + $addressee = $this->userRepository->find($message->getAddresseeId()); + + if (null === $notification) { + $this->logger->error('[SendImmediateNotificationEmailHandler] Notification not found', [ + 'notification_id' => $message->getNotificationId(), + ]); + + throw new \InvalidArgumentException(sprintf('Notification with ID %s not found', $message->getNotificationId())); + } + + if (null === $addressee) { + $this->logger->error('[SendImmediateNotificationEmailHandler] Addressee not found', [ + 'addressee_id' => $message->getAddresseeId(), + ]); + + throw new \InvalidArgumentException(sprintf('User with ID %s not found', $message->getAddresseeId())); + } + + try { + $this->notificationMailer->sendEmailToAddressee($notification, $addressee); + } catch (\Exception $e) { + $this->logger->error('[SendImmediateNotificationEmailHandler] Failed to send email', [ + 'notification_id' => $message->getNotificationId(), + 'addressee_id' => $message->getAddresseeId(), + 'stacktrace' => $e->getTraceAsString(), + ]); + throw $e; + } + } +} diff --git a/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailMessages/ScheduleDailyNotificationDigestMessage.php b/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailMessages/ScheduleDailyNotificationDigestMessage.php new file mode 100644 index 000000000..335185503 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailMessages/ScheduleDailyNotificationDigestMessage.php @@ -0,0 +1,36 @@ +userId; + } + + public function getLastExecutionDateTime(): \DateTimeInterface + { + return $this->lastExecutionDate; + } + + public function getCurrentDateTime(): \DateTimeInterface + { + return $this->currentDate; + } +} diff --git a/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailMessages/SendImmediateNotificationEmailMessage.php b/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailMessages/SendImmediateNotificationEmailMessage.php new file mode 100644 index 000000000..fb9908b21 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Notification/Email/NotificationEmailMessages/SendImmediateNotificationEmailMessage.php @@ -0,0 +1,30 @@ +notificationId; + } + + public function getAddresseeId(): int + { + return $this->addresseeId; + } +} diff --git a/src/Bundle/ChillMainBundle/Notification/Email/NotificationMailer.php b/src/Bundle/ChillMainBundle/Notification/Email/NotificationMailer.php index 7b535f1a7..2f888ffd5 100644 --- a/src/Bundle/ChillMainBundle/Notification/Email/NotificationMailer.php +++ b/src/Bundle/ChillMainBundle/Notification/Email/NotificationMailer.php @@ -13,22 +13,32 @@ namespace Chill\MainBundle\Notification\Email; use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\NotificationComment; +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage; use Doctrine\ORM\Event\PostPersistEventArgs; -use Doctrine\ORM\Event\PostUpdateEventArgs; use Psr\Log\LoggerInterface; use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Contracts\Translation\TranslatorInterface; -class NotificationMailer +readonly class NotificationMailer { - public function __construct(private readonly MailerInterface $mailer, private readonly LoggerInterface $logger, private readonly TranslatorInterface $translator) {} + public function __construct( + private MailerInterface $mailer, + private LoggerInterface $logger, + private MessageBusInterface $messageBus, + private TranslatorInterface $translator, + ) {} public function postPersistComment(NotificationComment $comment, PostPersistEventArgs $eventArgs): void { - $dests = [$comment->getNotification()->getSender(), ...$comment->getNotification()->getAddressees()->toArray()]; + $dests = [ + $comment->getNotification()->getSender(), + ...$comment->getNotification()->getAddressees()->toArray(), + ]; $uniqueDests = []; foreach ($dests as $dest) { @@ -69,55 +79,147 @@ class NotificationMailer */ public function postPersistNotification(Notification $notification, PostPersistEventArgs $eventArgs): void { - $this->sendNotificationEmailsToAddresses($notification); + $this->sendNotificationEmailsToAddressees($notification); $this->sendNotificationEmailsToAddressesEmails($notification); } - public function postUpdateNotification(Notification $notification, PostUpdateEventArgs $eventArgs): void + private function sendNotificationEmailsToAddressees(Notification $notification): void { - $this->sendNotificationEmailsToAddressesEmails($notification); - } + if ('' === $notification->getType()) { + $this->logger->warning('[NotificationMailer] Notification has no type, skipping email processing', [ + 'notification_id' => $notification->getId(), + ]); - private function sendNotificationEmailsToAddresses(Notification $notification): void - { - foreach ($notification->getAddressees() as $addressee) { + return; + } + + foreach ($notification->getAllAddressees() as $addressee) { if (null === $addressee->getEmail()) { continue; } - if ($notification->isSystem()) { - $email = new Email(); - $email - ->text($notification->getMessage()); - } else { - $email = new TemplatedEmail(); - $email - ->textTemplate('@ChillMain/Notification/email_non_system_notification_content.fr.md.twig') - ->context([ - 'notification' => $notification, - 'dest' => $addressee, - ]); - } + $this->processNotificationForAddressee($notification, $addressee); + } + } + private function processNotificationForAddressee(Notification $notification, User $addressee): void + { + $notificationType = $notification->getType(); + + if ($addressee->isNotificationSendImmediately($notificationType)) { + $this->scheduleImmediateEmail($notification, $addressee); + } + } + + private function scheduleImmediateEmail(Notification $notification, User $addressee): void + { + $message = new SendImmediateNotificationEmailMessage( + $notification->getId(), + $addressee->getId() + ); + + $this->messageBus->dispatch($message); + + $this->logger->info('[NotificationMailer] Scheduled immediate email', [ + 'notification_id' => $notification->getId(), + 'addressee_email' => $addressee->getEmail(), + ]); + } + + /** + * This method sends the email but is now called by the immediate notification email message handler. + * + * @throws TransportExceptionInterface + */ + public function sendEmailToAddressee(Notification $notification, User $addressee): void + { + if (null === $addressee->getEmail()) { + return; + } + + if ($notification->isSystem()) { + $email = new Email(); + $email->text($notification->getMessage()); + } else { + $email = new TemplatedEmail(); $email - ->subject($notification->getTitle()) - ->to($addressee->getEmail()); - - try { - $this->mailer->send($email); - } catch (TransportExceptionInterface $e) { - $this->logger->warning('[NotificationMailer] could not send an email notification', [ - 'to' => $addressee->getEmail(), - 'error_message' => $e->getMessage(), - 'error_trace' => $e->getTraceAsString(), + ->textTemplate('@ChillMain/Notification/email_non_system_notification_content.fr.md.twig') + ->context([ + 'notification' => $notification, + 'dest' => $addressee, ]); - } + } + + $email + ->subject($notification->getTitle()) + ->to($addressee->getEmail()); + + try { + $this->mailer->send($email); + $this->logger->info('[NotificationMailer] Email sent successfully', [ + 'notification_id' => $notification->getId(), + 'addressee_email' => $addressee->getEmail(), + ]); + } catch (TransportExceptionInterface $e) { + $this->logger->warning('[NotificationMailer] Could not send an email notification', [ + 'to' => $addressee->getEmail(), + 'notification_id' => $notification->getId(), + 'error_message' => $e->getMessage(), + 'error_trace' => $e->getTraceAsString(), + ]); + throw $e; + } + } + + /** + * Send daily digest email with multiple notifications to a user. + * + * @throws TransportExceptionInterface + */ + public function sendDailyDigest(User $user, array $notifications): void + { + if (null === $user->getEmail() || [] === $notifications) { + return; + } + + $email = new TemplatedEmail(); + $email + ->htmlTemplate('@ChillMain/Notification/email_daily_digest.fr.md.twig') + ->context([ + 'user' => $user, + 'notifications' => $notifications, + 'notification_count' => count($notifications), + ]) + ->subject($this->translator->trans('notification.Daily Notification Digest')) + ->to($user->getEmail()); + + try { + $this->mailer->send($email); + $this->logger->info('[NotificationMailer] Daily digest email sent successfully', [ + 'user_email' => $user->getEmail(), + 'notification_count' => count($notifications), + ]); + } catch (TransportExceptionInterface $e) { + $this->logger->warning('[NotificationMailer] Could not send daily digest email', [ + 'to' => $user->getEmail(), + 'notification_count' => count($notifications), + 'error_message' => $e->getMessage(), + 'error_trace' => $e->getTraceAsString(), + ]); + throw $e; } } private function sendNotificationEmailsToAddressesEmails(Notification $notification): void { - foreach ($notification->getAddressesEmailsAdded() as $emailAddress) { + foreach ($notification->getAddresseeUserGroups() as $userGroup) { + + if (!$userGroup->hasEmail()) { + continue; + } + + $emailAddress = $userGroup->getEmail(); + $email = new TemplatedEmail(); $email ->textTemplate('@ChillMain/Notification/email_non_system_notification_content_to_email.fr.md.twig') diff --git a/src/Bundle/ChillMainBundle/Notification/FlagProviders/NotificationByUserFlagProvider.php b/src/Bundle/ChillMainBundle/Notification/FlagProviders/NotificationByUserFlagProvider.php new file mode 100644 index 000000000..887d7f3d1 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Notification/FlagProviders/NotificationByUserFlagProvider.php @@ -0,0 +1,30 @@ + + */ + private array $notificationFlagProviders; + + public function __construct( + iterable $notificationFlagProviders, + ) { + $this->notificationFlagProviders = iterator_to_array($notificationFlagProviders); + } + + public function getAllNotificationFlagProviders(): array + { + return $this->notificationFlagProviders; + } +} diff --git a/src/Bundle/ChillMainBundle/Repository/NotificationRepository.php b/src/Bundle/ChillMainBundle/Repository/NotificationRepository.php index fb79a7397..99fb57094 100644 --- a/src/Bundle/ChillMainBundle/Repository/NotificationRepository.php +++ b/src/Bundle/ChillMainBundle/Repository/NotificationRepository.php @@ -290,12 +290,19 @@ final class NotificationRepository implements ObjectRepository return $qb; } - private function queryByAddressee(User $addressee, bool $countQuery = false): QueryBuilder + private function queryByAddressee(User $addressee): QueryBuilder { $qb = $this->repository->createQueryBuilder('n'); $qb - ->where($qb->expr()->isMemberOf(':addressee', 'n.addressees')) + ->leftJoin('n.addresseeUserGroups', 'aug') + ->leftJoin('aug.users', 'ugu') + ->where( + $qb->expr()->orX( + $qb->expr()->isMemberOf(':addressee', 'n.addressees'), + $qb->expr()->eq('ugu.id', ':addressee') + ) + ) ->setParameter('addressee', $addressee); return $qb; @@ -393,4 +400,30 @@ final class NotificationRepository implements ObjectRepository return $nq->getResult(); } + + /** + * Find all notifications for a user that were created between two dates. + * + * @return array|Notification[] + */ + public function findNotificationsForUserBetweenDates(int $userId, \DateTimeInterface $startDate, \DateTimeInterface $endDate): array + { + $rsm = new Query\ResultSetMappingBuilder($this->em); + $rsm->addRootEntityFromClassMetadata(Notification::class, 'cmn'); + + $sql = 'SELECT '.$rsm->generateSelectClause(['cmn' => 'cmn']).' '. + 'FROM chill_main_notification cmn '. + 'JOIN chill_main_notification_addresses_user cmnau ON cmnau.notification_id = cmn.id '. + 'WHERE cmnau.user_id = :userId '. + 'AND cmn.date >= :startDate '. + 'AND cmn.date <= :endDate '. + 'ORDER BY cmn.date DESC'; + + $nq = $this->em->createNativeQuery($sql, $rsm) + ->setParameter('userId', $userId) + ->setParameter('startDate', $startDate, Types::DATETIME_MUTABLE) + ->setParameter('endDate', $endDate, Types::DATETIME_MUTABLE); + + return $nq->getResult(); + } } diff --git a/src/Bundle/ChillMainBundle/Resources/public/chill/chillmain.scss b/src/Bundle/ChillMainBundle/Resources/public/chill/chillmain.scss index 1dc56dded..bb570f378 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/chill/chillmain.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/chill/chillmain.scss @@ -170,13 +170,14 @@ div.banner { font-weight: lighter; font-size: 50%; margin-left: 0.5em; - &:before { content: '(n°'; } - &:after { content: ')'; } + + &.same-size { + font-size: unset; + font-weight: unset; + } } span.age { margin-left: 0.5em; - &:before { content: '('; } - &:after { content: ')'; } } } diff --git a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/chill_variables.scss b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/chill_variables.scss index 2f113f45b..dce2b4a34 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/chill_variables.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/chill_variables.scss @@ -10,8 +10,9 @@ $chill-household-context: #929d69; // Badges colors $social-issue-color: #4bafe8; $social-action-color: $orange; +$event-theme-color: #ecc546; $activity-color: yellowgreen; // budget colors $budget-resource-color: #6d9e63; -$budget-charge-color: #e03851; \ No newline at end of file +$budget-charge-color: #e03851; diff --git a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/render_box.scss b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/render_box.scss index 57fa17648..7e854d1ca 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/render_box.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/render_box.scss @@ -44,8 +44,6 @@ section.chill-entity { margin-left: 0.5em; } span.id-number { - &:before { content: '(n°'; } - &:after { content: ')'; } } } p.moreinfo {} diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue index ede1b3778..76de138d0 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue @@ -12,22 +12,24 @@ ref="showAddress" /> - - - - + + + + @@ -62,13 +64,13 @@ > @@ -85,10 +87,12 @@ > @@ -108,17 +112,17 @@ @@ -139,19 +143,19 @@ > @@ -168,10 +172,12 @@ > @@ -190,10 +196,10 @@ @@ -213,13 +219,13 @@ @@ -241,9 +247,16 @@ import { postPostalCode, } from "../api"; import { - postAddressToPerson, - postAddressToHousehold, -} from "ChillPersonAssets/vuejs/_api/AddAddress.js"; + CREATE_A_NEW_ADDRESS, + ADDRESS_LOADING, + ACTIVITY_CREATE_ADDRESS, + ACTIVITY_EDIT_ADDRESS, + CANCEL, + SAVE, + PREVIOUS, + NEXT, + trans, +} from "translator"; import ShowPane from "./ShowPane.vue"; import SuggestPane from "./SuggestPane.vue"; import EditPane from "./EditPane.vue"; @@ -251,7 +264,17 @@ import DatePane from "./DatePane.vue"; export default { name: "AddAddress", - props: ["context", "options", "addressChangedCallback"], + setup() { + return { + trans, + CREATE_A_NEW_ADDRESS, + ADDRESS_LOADING, + CANCEL, + SAVE, + PREVIOUS, + NEXT, + }; + },props: ["context", "options", "addressChangedCallback"], components: { Modal, ShowPane, @@ -369,9 +392,11 @@ export default { typeof this.options.title !== "undefined" && (this.options.title.edit !== null || this.options.title.create !== null) ) { - return this.context.edit - ? this.options.title.edit - : this.options.title.create; + console.log("this.options.title", this.options.title); + + return this.context.edit + ? ACTIVITY_EDIT_ADDRESS + : ACTIVITY_CREATE_ADDRESS; } return this.context.edit ? this.defaultz.title.edit @@ -498,7 +523,7 @@ export default { getAddress(id) .then( (address) => - new Promise((resolve, reject) => { + new Promise((resolve) => { this.entity.address = address; this.flag.loading = false; resolve(); @@ -515,7 +540,7 @@ export default { fetchCountries() .then( (countries) => - new Promise((resolve, reject) => { + new Promise((resolve) => { this.entity.loaded.countries = countries.results; if (this.flag.showPane === true) { this.closeShowPane(); @@ -543,7 +568,7 @@ export default { fetchCities(country) .then( (cities) => - new Promise((resolve, reject) => { + new Promise((resolve) => { this.entity.loaded.cities = cities.results.filter( (c) => c.origin !== 3, ); // filter out user-defined cities @@ -562,7 +587,7 @@ export default { fetchReferenceAddresses(city) .then( (addresses) => - new Promise((resolve, reject) => { + new Promise((resolve) => { this.entity.loaded.addresses = addresses.results; this.flag.loading = false; resolve(); @@ -784,7 +809,7 @@ export default { return postAddress(payload) .then( (address) => - new Promise((resolve, reject) => { + new Promise((resolve) => { this.entity.address = address; this.flag.loading = false; this.flag.success = true; @@ -833,7 +858,7 @@ export default { return patchAddress(payload.addressId, payload.newAddress) .then( (address) => - new Promise((resolve, reject) => { + new Promise((resolve) => { this.entity.address = address; this.flag.loading = false; this.flag.success = true; diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressMore.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressMore.vue index 3e9da5da4..f90db6d05 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressMore.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressMore.vue @@ -1,6 +1,6 @@ + +thirdparty_duplicate: merge: Fussioner find: 'Désigner un tiers doublon' diff --git a/src/Bundle/ChillMainBundle/Resources/views/CRUD/_view_content.html.twig b/src/Bundle/ChillMainBundle/Resources/views/CRUD/_view_content.html.twig index 203a24926..71755cc11 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/CRUD/_view_content.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/CRUD/_view_content.html.twig @@ -4,7 +4,7 @@ {% endblock crud_content_header %} {% block crud_content_view %} - + {% block crud_content_view_details %}
      id
      @@ -20,7 +20,7 @@ {{ 'Cancel'|trans }} - {% endblock %} + {% endblock %} {% block content_view_actions_before %}{% endblock %} {% block content_form_actions_delete %} {% if chill_crud_action_exists(crud_name, 'delete') %} @@ -32,7 +32,7 @@ {% endif %} {% endif %} - {% endblock content_form_actions_delete %} + {% endblock content_form_actions_delete %} {% block content_view_actions_duplicate_link %} {% if chill_crud_action_exists(crud_name, 'new') %} {% if is_granted(chill_crud_config('role', crud_name, 'new'), entity) %} @@ -44,6 +44,17 @@ {% endif %} {% endif %} {% endblock content_view_actions_duplicate_link %} + {% block content_view_actions_merge %} +
    • + + + {{ 'Merge'|trans }} + +
    • + {% endblock %} {% block content_view_actions_edit_link %} {% if chill_crud_action_exists(crud_name, 'edit') %} {% if is_granted(chill_crud_config('role', crud_name, 'edit'), entity) %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Export/new_centers_step.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Export/new_centers_step.html.twig index 42d2d4574..137fb283f 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Export/new_centers_step.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Export/new_centers_step.html.twig @@ -63,8 +63,7 @@ diff --git a/src/Bundle/ChillMainBundle/Resources/views/FilterOrder/base.html.twig b/src/Bundle/ChillMainBundle/Resources/views/FilterOrder/base.html.twig index adf67b81b..555cb7da1 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/FilterOrder/base.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/FilterOrder/base.html.twig @@ -5,7 +5,7 @@ Filtrer la liste -
      +
      {% set btnSubmit = 0 %}
      @@ -68,10 +68,17 @@ {{ form_label(form.entity_choices[checkbox_name])}} {% endif %}
      - {% for c in form['entity_choices'][checkbox_name].children %} - {{ form_widget(c) }} - {{ form_label(c) }} - {% endfor %} + {% set field = form['entity_choices'][checkbox_name] %} + {% if field.vars.expanded %} + {# Render expanded checkboxes/radios #} + {% for c in field.children %} + {{ form_widget(c) }} + {{ form_label(c) }} + {% endfor %} + {% else %} + {# Render select dropdown #} + {{ form_widget(field) }} + {% endif %}
      {% endfor %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Form/bootstrap5/bootstrap_5_horizontal_layout.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Form/bootstrap5/bootstrap_5_horizontal_layout.html.twig index 1afdbb2c9..fa773d56e 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Form/bootstrap5/bootstrap_5_horizontal_layout.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Form/bootstrap5/bootstrap_5_horizontal_layout.html.twig @@ -18,8 +18,9 @@ {%- endif -%} {%- endblock form_label %} +{# this has been rewritten for chill #} {% block form_label_class -%} - col-sm-4 + {% if 'div_col_width' in label_attr|default({})|keys %}{% if label_attr['div_col_width'] is not same as false %}{{ label_attr['div_col_width'] }}{% endif %}{% else %}col-sm-4{% endif %} {%- endblock form_label_class %} {# Rows #} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Layout/_header_logo_only.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Layout/_header_logo_only.html.twig new file mode 100644 index 000000000..78003c14b --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/Layout/_header_logo_only.html.twig @@ -0,0 +1,13 @@ +
      + +
      diff --git a/src/Bundle/ChillMainBundle/Resources/views/Menu/user.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Menu/user.html.twig index 52be8b50f..998a1b41a 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Menu/user.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Menu/user.html.twig @@ -26,11 +26,12 @@ {{ 'Welcome' | trans }}
      - - {{ app.user.username }} - {{ render(controller('Chill\\MainBundle\\Controller\\UIController::showNotificationUserCounterAction')) }} - - + {% if app.user %} + + {{ app.user.username }} + {{ render(controller('Chill\\MainBundle\\Controller\\UIController::showNotificationUserCounterAction')) }} + + {% endif %} {% if is_granted('IS_IMPERSONATOR') %} {% endif %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/_list_item.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/_list_item.html.twig index f9516f92f..0e43b73e6 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Notification/_list_item.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/_list_item.html.twig @@ -69,41 +69,44 @@ {% endif %} {% endif %} - {% if c.notification.addressees|length > 0 %} + {% if c.notification.addressees|length > 0 or c.notification.addresseeUserGroups|length > 0 %}
    • {% if c.notification_cc is defined %} {% if c.notification_cc %} - - {{ "notification.cc" | trans }} : - - + + {{ "notification.cc" | trans }} : + + {% else %} - - {{ "notification.to" | trans }} : - - + + {{ "notification.to" | trans }} : + + {% endif %} {% else %} - - {{ "notification.to" | trans }} : - - + + {{ "notification.to" | trans }} : + + {% endif %} {% for a in c.notification.addressees %} - {{ a | chill_entity_render_string({'at_date': c.notification.date}) }} - + {{ a | chill_entity_render_string({'at_date': c.notification.date}) }} + {% endfor %} {% for a in c.notification.addressesEmails %} - {{ a }} - + {{ a }} + + {% endfor %} + {% for ug in c.notification.addresseeUserGroups %} + {{ ug|chill_entity_render_box }} {% endfor %}
    • {% endif %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/create.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/create.html.twig index 4dfd340b6..8797c276a 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Notification/create.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/create.html.twig @@ -21,8 +21,6 @@ {{ form_row(form.title, { 'label': 'notification.subject'|trans }) }} {{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }} - {{ form_row(form.addressesEmails) }} - {% include handler.template(notification) with handler.templateData(notification) %}
      diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/email_daily_digest.fr.md.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_daily_digest.fr.md.twig new file mode 100644 index 000000000..084b0d307 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_daily_digest.fr.md.twig @@ -0,0 +1,24 @@ +{% apply markdown_to_html %} +# {{ 'notification.daily_digest.title'|trans }} + +{{ 'notification.daily_digest.greeting'|trans({'%user%': user.label ?? user.email}) }}, + +{{ 'daily_notifications'|trans({'notification_count': notification_count}) }} + +{% for notification in notifications %} +## {{ notification.title }} + +{{ notification.message }} + +{{ 'notification.daily_digest.view_notification'|trans }} + +{{ absolute_url(path('chill_main_notification_show', {'_locale': user.locale, 'id': notification.id }, false)) }} + +{% if not loop.last %} +--- +{% endif %} +{% endfor %} + +--- +{{ 'notification.daily_digest.signature'|trans }} +{% endapply %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Password/recover_layout.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Password/recover_layout.html.twig index 7c328b889..e51e7a007 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Password/recover_layout.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Password/recover_layout.html.twig @@ -16,29 +16,16 @@ * along with this program. If not, see . #} - - - - - - {{ 'Login to %installation_name%' | trans({ '%installation_name%' : installation.name } ) }} - - - {{ encore_entry_link_tags('chill') }} - - - +{% extends "@ChillMain/layout.html.twig" %} -
      - {% block content %}{% endblock %} -
      - - +{% set header_logo_only = 1 %} + +{% block title %}{{ 'Login to %installation_name%' | trans({ '%installation_name%' : installation.name } ) }}{% endblock %} + +{% block content %} + +
      + {% block password_content %}{% endblock %} +
      + +{% endblock %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Password/recover_password_changed.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Password/recover_password_changed.html.twig index 1af108bf1..c07a7b980 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Password/recover_password_changed.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Password/recover_password_changed.html.twig @@ -2,7 +2,7 @@ {% block title %}{{ "New password set"|trans }}{% endblock %} -{% block content %} +{% block password_content %}

      {{ "New password set"|trans }}

      diff --git a/src/Bundle/ChillMainBundle/Resources/views/Password/recover_password_form.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Password/recover_password_form.html.twig index 6e32cb0b4..32e5618d4 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Password/recover_password_form.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Password/recover_password_form.html.twig @@ -4,7 +4,7 @@ {% block title %}{{ title }}{% endblock %} -{% block content %} +{% block password_content %}

      {{ title }}

      diff --git a/src/Bundle/ChillMainBundle/Resources/views/Password/request_recover_password.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Password/request_recover_password.html.twig index 43a2d4674..2ff32f2c1 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Password/request_recover_password.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Password/request_recover_password.html.twig @@ -22,7 +22,7 @@ {% block title %}{{"Recover password"|trans}}{% endblock %} -{% block content %} +{% block password_content %}

      {{ 'Recover password'|trans }}

      diff --git a/src/Bundle/ChillMainBundle/Resources/views/Password/request_recover_password_confirm.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Password/request_recover_password_confirm.html.twig index 4fb924077..8014179e5 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Password/request_recover_password_confirm.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Password/request_recover_password_confirm.html.twig @@ -2,7 +2,7 @@ {% block title "Check your email"|trans %} -{% block content %} +{% block password_content %}
      diff --git a/src/Bundle/ChillMainBundle/Resources/views/User/profile.html.twig b/src/Bundle/ChillMainBundle/Resources/views/User/profile.html.twig index 360d748a5..d25f6645f 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/User/profile.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/User/profile.html.twig @@ -20,7 +20,7 @@ {% extends "@ChillMain/layout.html.twig" %} -{% block title %}{{"My profile"|trans}}{% endblock %} +{% block title %}{{"user.profile.title"|trans}}{% endblock %} {% block content %}
      @@ -45,9 +45,35 @@ {{ form_start(form) }} {{ form_row(form.phonenumber) }} +

      {{ 'user.profile.notification_preferences'|trans }}

      + + + + + + + + + + {% for flag in form.notificationFlags %} + + + + + + {% endfor %} + +
      {{ 'notification.flags.type'|trans }}{{ 'notification.flags.preferences.immediate_email'|trans }}{{ 'notification.flags.preferences.daily_email'|trans }}
      + {{ form_label(flag, null, {'label_attr': {'div_col_width': false}}) }} + + {{ form_widget(flag.immediate_email, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }} + + {{ form_widget(flag.daily_email, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }} +
      +
      • - {{ form_widget(form.submit, { 'attr': { 'class': 'btn btn-save' } } ) }} +
      diff --git a/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig b/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig index 4a3fef4e7..7827321eb 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig @@ -30,7 +30,11 @@ {{ include('@ChillMain/Layout/_debug.html.twig') }} {% endif %} - {{ include('@ChillMain/Layout/_header.html.twig') }} + {% if header_logo_only is defined and header_logo_only == 1 %} + {{ include('@ChillMain/Layout/_header_logo_only.html.twig') }} + {% else %} + {{ include('@ChillMain/Layout/_header.html.twig') }} + {% endif %} {% block top_banner %}{# To use if you want to add a banner below the header (ie the menu) diff --git a/src/Bundle/ChillMainBundle/Tests/Entity/NotificationTest.php b/src/Bundle/ChillMainBundle/Tests/Entity/NotificationTest.php index 2e945dcb4..e9e7ff760 100644 --- a/src/Bundle/ChillMainBundle/Tests/Entity/NotificationTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Entity/NotificationTest.php @@ -9,7 +9,7 @@ declare(strict_types=1); * the LICENSE file that was distributed with this source code. */ -namespace Entity; +namespace Chill\MainBundle\Tests\Entity; use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\User; @@ -49,8 +49,8 @@ final class NotificationTest extends KernelTestCase $notification = new Notification(); $notification->addAddressee($user1 = new User()); $notification->addAddressee($user2 = new User()); - $notification->getAddressees()->add($user3 = new User()); - $notification->getAddressees()->add($user4 = new User()); + $notification->addAddressee($user3 = new User()); + $notification->addAddressee($user4 = new User()); $this->assertCount(4, $notification->getAddressees()); @@ -85,6 +85,30 @@ final class NotificationTest extends KernelTestCase $this->assertNotContains('other', $notification->getAddressesEmailsAdded()); } + public function testIsSendImmediately(): void + { + $notification = new Notification(); + $notification->setType('test_notification_type'); + + $user = new User(); + + // no notification flags + $this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return true when no notification flags are set, by default immediate email'); + + // immediate-email preference + $user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_IMMEDIATE_EMAIL, User::NOTIF_FLAG_DAILY_DIGEST]]); + $this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return true when preferences contain immediate-email'); + + // daily-email preference + $user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_DAILY_DIGEST]]); + $this->assertFalse($user->isNotificationSendImmediately($notification->getType()), 'Should return false when preference is daily-email only'); + $this->assertTrue($user->isNotificationDailyDigest($notification->getType()), 'Should return true when preference is daily-email'); + + // a different notification type + $notification->setType('other_notification_type'); + $this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return false when notification type does not match any preference'); + } + /** * @dataProvider generateNotificationData */ diff --git a/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobFunctionalTest.php b/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobFunctionalTest.php new file mode 100644 index 000000000..0caeebc36 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobFunctionalTest.php @@ -0,0 +1,46 @@ +dailyNotificationDigestCronjob = self::getContainer()->get(DailyNotificationDigestCronjob::class); + } + + public function testRunWithNullPreviousExecutionData(): void + { + $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' + ); + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobTest.php b/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobTest.php new file mode 100644 index 000000000..5894385c6 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobTest.php @@ -0,0 +1,81 @@ +clock = $this->createMock(ClockInterface::class); + $this->connection = $this->createMock(Connection::class); + $this->messageBus = $this->createMock(MessageBusInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->cronjob = new DailyNotificationDigestCronjob( + $this->clock, + $this->connection, + $this->messageBus, + $this->logger + ); + } + + public function testGetKey(): void + { + $this->assertEquals('daily-notification-digest', $this->cronjob->getKey()); + } + + /** + * @dataProvider canRunTimeDataProvider + */ + public function testCanRunWithNullCronJobExecution(int $hour, bool $expected): void + { + $now = new \DateTimeImmutable("2024-01-01 {$hour}:00:00"); + $this->clock->expects($this->once()) + ->method('now') + ->willReturn($now); + + $result = $this->cronjob->canRun(null); + + $this->assertEquals($expected, $result); + } + + public static function canRunTimeDataProvider(): array + { + return [ + 'hour 5 - should not run' => [5, false], + 'hour 6 - should run' => [6, true], + 'hour 7 - should run' => [7, true], + 'hour 8 - should run' => [8, true], + 'hour 9 - should not run' => [9, false], + 'hour 10 - should not run' => [10, false], + 'hour 23 - should not run' => [23, false], + ]; + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Notification/Email/NotificationMailerTest.php b/src/Bundle/ChillMainBundle/Tests/Notification/Email/NotificationMailerTest.php index fad4a89f5..03be565a2 100644 --- a/src/Bundle/ChillMainBundle/Tests/Notification/Email/NotificationMailerTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Notification/Email/NotificationMailerTest.php @@ -17,13 +17,18 @@ use Chill\MainBundle\Entity\User; use Chill\MainBundle\Notification\Email\NotificationMailer; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Event\PostPersistEventArgs; +use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Log\NullLogger; use Symfony\Component\Mailer\MailerInterface; +use Symfony\Component\Messenger\Envelope; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Mime\Email; use Symfony\Component\Translation\Translator; +use Symfony\Contracts\Translation\TranslatorInterface; +use Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage; /** * @internal @@ -112,13 +117,149 @@ class NotificationMailerTest extends TestCase $mailer->postPersistComment($comment, new PostPersistEventArgs($comment, $objectManager->reveal())); } + /** + * @throws \ReflectionException + * @throws Exception + */ + public function testProcessNotificationForAddresseeWithImmediateEmailPreference(): void + { + // Create a real notification entity + $notification = new Notification(); + $notification->setType('test_notification_type'); + + // Use reflection to set the ID since it's normally generated by the database + $reflectionNotification = new \ReflectionClass(Notification::class); + $idProperty = $reflectionNotification->getProperty('id'); + $idProperty->setAccessible(true); + $idProperty->setValue($notification, 123); + + // Create a real user entity + $user = new User(); + $user->setEmail('user@example.com'); + + // Use reflection to set the ID since it's normally generated by the database + $reflectionUser = new \ReflectionClass(User::class); + $idProperty = $reflectionUser->getProperty('id'); + $idProperty->setAccessible(true); + $idProperty->setValue($user, 456); + + // Set notification flags for the user + $user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_IMMEDIATE_EMAIL]]); + + $messageBus = $this->createMock(MessageBusInterface::class); + $messageBus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(fn (SendImmediateNotificationEmailMessage $message) => 123 === $message->getNotificationId() + && 456 === $message->getAddresseeId())) + ->willReturn(new Envelope(new \stdClass())); + + $mailer = $this->buildNotificationMailer(null, $messageBus); + + // Call the method that processes notifications + $reflection = new \ReflectionClass(NotificationMailer::class); + $method = $reflection->getMethod('processNotificationForAddressee'); + $method->setAccessible(true); + $method->invoke($mailer, $notification, $user); + } + + public function testSendDailyDigest(): void + { + // Create a user + $user = new User(); + $user->setEmail('user@example.com'); + + // Create some notifications + $notification = $this->prophesize(Notification::class); + $notification->getTitle()->willReturn('Notification 1'); + $notification->getMessage()->willReturn('Message 1'); + $notification->getId()->willReturn(123); + + $notification2 = $this->prophesize(Notification::class); + $notification2->getTitle()->willReturn('Notification 2'); + $notification2->getMessage()->willReturn('Message 2'); + $notification2->getId()->willReturn(456); + + $notifications = [$notification, $notification2]; + + // Mock the mailer to verify that an email is sent with the correct parameters + $mailer = $this->prophesize(MailerInterface::class); + $mailer->send(Argument::that(function ($email) use ($user) { + // Verify that the email is sent to the correct user + foreach ($email->getTo() as $address) { + if ($user->getEmail() === $address->getAddress()) { + return true; + } + } + + return false; + }))->shouldBeCalledOnce(); + + // Create a translator that returns a fixed string for the subject + $translator = $this->prophesize(TranslatorInterface::class); + $translator->trans('notification.Daily Notification Digest')->willReturn('Daily Digest'); + + // Create the notification mailer with the mocked mailer and translator + $notificationMailer = $this->buildNotificationMailer($mailer->reveal(), null, $translator->reveal()); + + // Call the sendDailyDigest method + $notificationMailer->sendDailyDigest($user, $notifications); + } + + public function testSendDailyDigestWithNoNotifications(): void + { + // Create a user + $user = new User(); + $user->setEmail('user@example.com'); + + // Empty notifications array + $notifications = []; + + // Mock the mailer to verify that no email is sent + $mailer = $this->prophesize(MailerInterface::class); + $mailer->send(Argument::any())->shouldNotBeCalled(); + + // Create the notification mailer with the mocked mailer + $notificationMailer = $this->buildNotificationMailer($mailer->reveal()); + + // Call the sendDailyDigest method + $notificationMailer->sendDailyDigest($user, $notifications); + } + + public function testSendDailyDigestWithUserHavingNoEmail(): void + { + // Create a user with no email + $user = new User(); + $user->setEmail(null); + + // Create some notifications + $notification = $this->prophesize(Notification::class); + $notification->getTitle()->willReturn('Notification 1'); + $notification->getMessage()->willReturn('Message 1'); + $notification->getId()->willReturn(123); + + $notifications = [$notification]; + + // Mock the mailer to verify that no email is sent + $mailer = $this->prophesize(MailerInterface::class); + $mailer->send(Argument::any())->shouldNotBeCalled(); + + // Create the notification mailer with the mocked mailer + $notificationMailer = $this->buildNotificationMailer($mailer->reveal()); + + // Call the sendDailyDigest method + $notificationMailer->sendDailyDigest($user, $notifications); + } + private function buildNotificationMailer( ?MailerInterface $mailer = null, + ?MessageBusInterface $messageBus = null, + ?TranslatorInterface $translator = null, ): NotificationMailer { return new NotificationMailer( - $mailer, + $mailer ?? $this->prophesize(MailerInterface::class)->reveal(), new NullLogger(), - new Translator('fr') + $messageBus ?? $this->prophesize(MessageBusInterface::class)->reveal(), + $translator ?? new Translator('fr') ); } } diff --git a/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationOnTransition.php b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationOnTransition.php index 17f5194cc..3775869e7 100644 --- a/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationOnTransition.php +++ b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationOnTransition.php @@ -15,6 +15,7 @@ use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\UserGroup; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; +use Chill\MainBundle\Notification\FlagProviders\WorkflowTransitionNotificationFlagProvider; use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\Helper\MetadataExtractor; use Doctrine\ORM\EntityManagerInterface; @@ -125,7 +126,8 @@ class NotificationOnTransition implements EventSubscriberInterface ->setRelatedEntityClass(EntityWorkflow::class) ->setTitle($this->engine->render('@ChillMain/Workflow/workflow_notification_on_transition_completed_title.fr.txt.twig', $context)) ->setMessage($this->engine->render('@ChillMain/Workflow/workflow_notification_on_transition_completed_content.fr.txt.twig', $context)) - ->addAddressee($subscriber); + ->addAddressee($subscriber) + ->setType(WorkflowTransitionNotificationFlagProvider::FLAG); $this->entityManager->persist($notification); } } diff --git a/src/Bundle/ChillMainBundle/config/services/form.yaml b/src/Bundle/ChillMainBundle/config/services/form.yaml index f31829915..e917b37c9 100644 --- a/src/Bundle/ChillMainBundle/config/services/form.yaml +++ b/src/Bundle/ChillMainBundle/config/services/form.yaml @@ -139,6 +139,11 @@ services: autowire: true autoconfigure: true + Chill\MainBundle\Form\DataMapper\NotificationFlagDataMapper: + autowire: true + autoconfigure: true + + Chill\MainBundle\Form\UserProfileType: ~ Chill\MainBundle\Form\AbsenceType: ~ Chill\MainBundle\Form\DataMapper\RegroupmentDataMapper: ~ Chill\MainBundle\Form\RegroupmentType: ~ diff --git a/src/Bundle/ChillMainBundle/config/services/notification.yaml b/src/Bundle/ChillMainBundle/config/services/notification.yaml index be3252003..ff3087ddf 100644 --- a/src/Bundle/ChillMainBundle/config/services/notification.yaml +++ b/src/Bundle/ChillMainBundle/config/services/notification.yaml @@ -12,6 +12,10 @@ services: arguments: $routeParameters: '%chill_main.notifications%' + Chill\MainBundle\Notification\NotificationFlagManager: + arguments: + $notificationFlagProviders: !tagged_iterator chill_main.notification_flag_provider + Chill\MainBundle\Notification\NotificationHandlerManager: arguments: $handlers: !tagged_iterator chill_main.notification_handler @@ -55,14 +59,6 @@ services: lazy: true method: 'postPersistNotification' - - - name: 'doctrine.orm.entity_listener' - event: 'postUpdate' - entity: 'Chill\MainBundle\Entity\Notification' - # set the 'lazy' option to TRUE to only instantiate listeners when they are used - lazy: true - method: 'postUpdateNotification' - - name: 'doctrine.orm.entity_listener' event: 'postPersist' diff --git a/src/Bundle/ChillMainBundle/migrations/Version20250610102953.php b/src/Bundle/ChillMainBundle/migrations/Version20250610102953.php new file mode 100644 index 000000000..6d6d2c2ab --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20250610102953.php @@ -0,0 +1,37 @@ +addSql(<<<'SQL' + ALTER TABLE users ADD notificationFlags JSONB DEFAULT '[]' NOT NULL + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE users DROP notificationFlags + SQL); + } +} diff --git a/src/Bundle/ChillMainBundle/migrations/Version20250618115938.php b/src/Bundle/ChillMainBundle/migrations/Version20250618115938.php new file mode 100644 index 000000000..c3a09062f --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20250618115938.php @@ -0,0 +1,37 @@ +addSql(<<<'SQL' + ALTER TABLE chill_main_notification ADD type VARCHAR(255) NOT NULL DEFAULT 'default_notification_type' + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE chill_main_notification DROP type + SQL); + } +} diff --git a/src/Bundle/ChillMainBundle/migrations/Version20250623120824.php b/src/Bundle/ChillMainBundle/migrations/Version20250623120824.php new file mode 100644 index 000000000..3cf715db5 --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20250623120824.php @@ -0,0 +1,55 @@ +addSql(<<<'SQL' + CREATE TABLE chill_main_notification_addressee_user_group (notification_id INT NOT NULL, usergroup_id INT NOT NULL, PRIMARY KEY(notification_id, usergroup_id)) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_ECF81C07EF1A9D84 ON chill_main_notification_addressee_user_group (notification_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_ECF81C07D2112630 ON chill_main_notification_addressee_user_group (usergroup_id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_main_notification_addressee_user_group ADD CONSTRAINT FK_ECF81C07EF1A9D84 FOREIGN KEY (notification_id) REFERENCES chill_main_notification (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_main_notification_addressee_user_group ADD CONSTRAINT FK_ECF81C07D2112630 FOREIGN KEY (usergroup_id) REFERENCES chill_main_user_group (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE chill_main_notification_addressee_user_group DROP CONSTRAINT FK_ECF81C07EF1A9D84 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_main_notification_addressee_user_group DROP CONSTRAINT FK_ECF81C07D2112630 + SQL); + $this->addSql(<<<'SQL' + DROP TABLE chill_main_notification_addressee_user_group + SQL); + } +} diff --git a/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml index 0d1b30ef8..15bc7a84e 100644 --- a/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml +++ b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml @@ -49,6 +49,12 @@ notification: other {# commentaires} } +daily_notifications: >- + {notification_count, plural, + =1 {Voici votre notification du jour :} + other {Voici vos # notifications du jour :} + } + workflow: My workflows with counter: >- {wc, plural, diff --git a/src/Bundle/ChillMainBundle/translations/messages.fr.yml b/src/Bundle/ChillMainBundle/translations/messages.fr.yml index 5e591fba8..f3f4a89d5 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.fr.yml @@ -52,9 +52,10 @@ user: current_user: Utilisateur courant profile: title: Mon profil - Phonenumber successfully updated!: Numéro de téléphone mis à jour! + Profile successfully updated!: Votre profil a été mis à jour! no job: Pas de métier assigné no scope: Pas de cercle assigné + notification_preferences: Préférences pour mes notifications user_group: inactive: Inactif @@ -674,6 +675,7 @@ Subscribe all steps: Recevoir une notification à chaque étape CHILL_MAIN_WORKFLOW_APPLY_ALL_TRANSITION: Appliquer les transitions sur tous les workflows notification: + Daily Notification Digest: Résumé des notifications quotidiennes Notification: Notification Notifications: Notifications My own notifications: Mes notifications @@ -712,13 +714,36 @@ notification: dest by email help: Les adresses email mentionnées ici recevront un lien d'accès. Un compte utilisateur sera toujours nécessaire. Remove an email: Supprimer l'adresse email Email with access link: Adresse email ayant reçu un lien d'accès + Pick user or user group: Selectionner un utilisateur / groupe d'utilisateurs mark_as_read: Marquer comme lu mark_as_unread: Marquer comme non-lu + flags: + type: Type de notification + by-user: Lorsqu'un utilisateur vous envoie une notification personnelle + referrer-acc-course: Lorsqu'un autre utilisateur vous désigne comme référent d'un parcours + person-address-move: Lorsqu'un autre utilisateur enregistre le déménagement d'un usager concerné par un parcours dont vous êtes le référent. + person: Notification sur un usager + workflow-trans: Lorsqu'un autre utilisateur applique une transition à un workflow. + none selected message: Si vous ne sélectionnez aucune option, vous ne recevrez pas d'email concernant ce type de notification. + preferences: + column_title: Préférences + immediate_email: Email immédiat + daily_email: Récapitulatif quotidien + no_email: Ne pas recevoir un email + + daily_digest: + title: "Résumé quotidien des notifications" + greeting: "Bonjour %user%" + intro: "Vous avez reçu %notification_count% nouvelle(s) notification(s)." + view_notification: "Vous pouvez visualiser la notification et y répondre ici:" + signature: "Le logiciel Chill" + CHILL_MAIN_COMPOSE_EXPORT: Exécuter des exports et les sauvegarder CHILL_MAIN_GENERATE_SAVED_EXPORT: Exécuter et modifier des exports préalablement sauvegardés + export: role: export_role: Exports diff --git a/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Events/PersonAddressMoveEventSubscriber.php b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Events/PersonAddressMoveEventSubscriber.php index 64d2b8f12..8093a695c 100644 --- a/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Events/PersonAddressMoveEventSubscriber.php +++ b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Events/PersonAddressMoveEventSubscriber.php @@ -16,6 +16,7 @@ use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Notification\NotificationPersisterInterface; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Event\Person\PersonAddressMoveEvent; +use Chill\PersonBundle\Notification\FlagProviders\PersonAddressMoveNotificationFlagProvider; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Security\Core\Security; use Symfony\Contracts\Translation\TranslatorInterface; @@ -65,7 +66,8 @@ class PersonAddressMoveEventSubscriber implements EventSubscriberInterface ->setMessage($this->engine->render('@ChillPerson/AccompanyingPeriod/notification_location_user_on_period_has_moved.fr.txt.twig', [ 'oldPersonLocation' => $person, 'period' => $period, - ])); + ])) + ->setType(PersonAddressMoveNotificationFlagProvider::FLAG); $this->notificationPersister->persist($notification); } diff --git a/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Events/UserRefEventSubscriber.php b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Events/UserRefEventSubscriber.php index de1eac37d..253de9fb9 100644 --- a/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Events/UserRefEventSubscriber.php +++ b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Events/UserRefEventSubscriber.php @@ -15,6 +15,7 @@ use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Notification\NotificationPersisterInterface; use Chill\PersonBundle\Entity\AccompanyingPeriod; +use Chill\PersonBundle\Notification\FlagProviders\DesignatedReferrerNotificationFlagProvider; use Doctrine\Persistence\Event\LifecycleEventArgs; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Security\Core\Security; @@ -73,7 +74,8 @@ class UserRefEventSubscriber implements EventSubscriberInterface 'accompanyingCourse' => $period, ] )) - ->addAddressee($period->getUser()); + ->addAddressee($period->getUser()) + ->setType(DesignatedReferrerNotificationFlagProvider::FLAG); $this->notificationPersister->persist($notification); } diff --git a/src/Bundle/ChillPersonBundle/ChillPersonBundle.php b/src/Bundle/ChillPersonBundle/ChillPersonBundle.php index 89477a47b..2d567aaa2 100644 --- a/src/Bundle/ChillPersonBundle/ChillPersonBundle.php +++ b/src/Bundle/ChillPersonBundle/ChillPersonBundle.php @@ -11,9 +11,11 @@ declare(strict_types=1); namespace Chill\PersonBundle; +use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface; use Chill\PersonBundle\Actions\Remove\PersonMoveSqlHandlerInterface; use Chill\PersonBundle\DependencyInjection\CompilerPass\AccompanyingPeriodTimelineCompilerPass; use Chill\PersonBundle\Export\Helper\CustomizeListPersonHelperInterface; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface; use Chill\PersonBundle\Service\EntityInfo\AccompanyingPeriodInfoUnionQueryPartInterface; use Chill\PersonBundle\Widget\PersonListWidgetFactory; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -35,5 +37,9 @@ class ChillPersonBundle extends Bundle ->addTag('chill_person.person_move_handler'); $container->registerForAutoconfiguration(CustomizeListPersonHelperInterface::class) ->addTag('chill_person.list_person_customizer'); + $container->registerForAutoconfiguration(NotificationFlagProviderInterface::class) + ->addTag('chill_main.notification_flag_provider'); + $container->registerForAutoconfiguration(PersonIdentifierEngineInterface::class) + ->addTag('chill_person.person_identifier_engine'); } } diff --git a/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseWorkController.php b/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseWorkController.php index 523d5a875..29362ba83 100644 --- a/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseWorkController.php +++ b/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseWorkController.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Chill\PersonBundle\Controller; +use Chill\MainBundle\Entity\User; use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Templating\Listing\FilterOrderHelper; use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface; @@ -130,6 +131,7 @@ final class AccompanyingCourseWorkController extends AbstractController $this->denyAccessUnlessGranted(AccompanyingPeriodWorkVoter::SEE, $period); $filter = $this->buildFilterOrder($period); + $currentUser = $this->getUser(); $filterData = [ 'types' => $filter->hasEntityChoice('typesFilter') ? $filter->getEntityChoiceData('typesFilter') : [], @@ -138,6 +140,10 @@ final class AccompanyingCourseWorkController extends AbstractController 'user' => $filter->getUserPickerData('userFilter'), ]; + if ($filter->getSingleCheckboxData('currentUserFilter') && $currentUser instanceof User) { + $filterData['currentUser'] = $currentUser; + } + $totalItems = $this->workRepository->countByAccompanyingPeriod($period); $paginator = $this->paginator->create($totalItems); @@ -201,6 +207,8 @@ final class AccompanyingCourseWorkController extends AbstractController ->addUserPicker('userFilter', 'accompanying_course_work.user_filter', ['required' => false]) ; + $filterBuilder->addSingleCheckbox('currentUserFilter', 'accompanying_course_work.my_actions_filter'); + return $filterBuilder->build(); } } diff --git a/src/Bundle/ChillPersonBundle/Controller/PersonController.php b/src/Bundle/ChillPersonBundle/Controller/PersonController.php index 9f35e1b5c..4698373f1 100644 --- a/src/Bundle/ChillPersonBundle/Controller/PersonController.php +++ b/src/Bundle/ChillPersonBundle/Controller/PersonController.php @@ -17,7 +17,6 @@ use Chill\PersonBundle\Entity\Household\Household; use Chill\PersonBundle\Entity\Household\HouseholdMember; use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Form\CreationPersonType; -use Chill\PersonBundle\Form\PersonType; use Chill\PersonBundle\Privacy\PrivacyEvent; use Chill\PersonBundle\Repository\PersonRepository; use Chill\PersonBundle\Search\SimilarPersonMatcher; @@ -49,56 +48,6 @@ final class PersonController extends AbstractController private readonly EntityManagerInterface $em, ) {} - #[Route(path: '/{_locale}/person/{person_id}/general/edit', name: 'chill_person_general_edit')] - public function editAction(int $person_id, Request $request) - { - $person = $this->_getPerson($person_id); - - if (null === $person) { - throw $this->createNotFoundException(); - } - - $this->denyAccessUnlessGranted( - 'CHILL_PERSON_UPDATE', - $person, - 'You are not allowed to edit this person' - ); - - $form = $this->createForm( - PersonType::class, - $person, - [ - 'cFGroup' => $this->getCFGroup(), - ] - ); - - $form->handleRequest($request); - - if ($form->isSubmitted() && !$form->isValid()) { - $this->get('session') - ->getFlashBag()->add('error', $this->translator - ->trans('This form contains errors')); - } elseif ($form->isSubmitted() && $form->isValid()) { - $this->em->flush(); - - $this->get('session')->getFlashBag() - ->add( - 'success', - $this->translator - ->trans('The person data has been updated') - ); - - return $this->redirectToRoute('chill_person_view', [ - 'person_id' => $person->getId(), - ]); - } - - return $this->render( - '@ChillPerson/Person/edit.html.twig', - ['person' => $person, 'form' => $form->createView()] - ); - } - public function getCFGroup() { $cFGroup = null; diff --git a/src/Bundle/ChillPersonBundle/Controller/PersonEditController.php b/src/Bundle/ChillPersonBundle/Controller/PersonEditController.php new file mode 100644 index 000000000..be5b87bc3 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Controller/PersonEditController.php @@ -0,0 +1,79 @@ +security->isGranted(PersonVoter::UPDATE, $person)) { + throw new AccessDeniedHttpException('You are not allowed to edit this person.'); + } + + $form = $this->formFactory->create( + PersonType::class, + $person, + ['cFGroup' => $this->customFieldsDefaultGroupRepository->findOneByEntity(Person::class)?->getCustomFieldsGroup()] + ); + + $form->handleRequest($request); + + if ($form->isSubmitted() && !$form->isValid()) { + $session + ->getFlashBag()->add('error', new TranslatableMessage('This form contains errors')); + } elseif ($form->isSubmitted() && $form->isValid()) { + $this->entityManager->flush(); + + $session->getFlashBag()->add('success', new TranslatableMessage('The person data has been updated')); + + return new RedirectResponse( + $this->urlGenerator->generate('chill_person_view', ['person_id' => $person->getId()]) + ); + } + + return new Response($this->twig->render('@ChillPerson/Person/edit.html.twig', [ + 'form' => $form->createView(), + 'person' => $person, + ])); + } +} diff --git a/src/Bundle/ChillPersonBundle/DependencyInjection/Configuration.php b/src/Bundle/ChillPersonBundle/DependencyInjection/Configuration.php index 12c8b4c5b..da87ae050 100644 --- a/src/Bundle/ChillPersonBundle/DependencyInjection/Configuration.php +++ b/src/Bundle/ChillPersonBundle/DependencyInjection/Configuration.php @@ -110,6 +110,24 @@ class Configuration implements ConfigurationInterface ->end() ->end() // children for 'person_fields', parent = array 'person_fields' ->end() // person_fields, parent = children of root + ->arrayNode('person_render') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('id_content_text') + ->defaultValue('n°[[ person_id ]]') + ->info( + <<<'EOF' + The way we display the person's id. Variables availables: "[[ person_id ]]", or, for person's + identifier: "[[ identifier_xx ]]" where xx is the identifier's definition's id. + + There are also conditions available: "[[ if:identifier_yy ]] [[ identifier_yy ]] [[ endif:identifier_yy ]]" + + Take care of keeping exactly one space between "[[" and the placeholder's content, and exactly one space before "]]" + EOF + ) + ->end() + ->end() // end of person_render's children + ->end() // end of person_render ->arrayNode('household_fields') ->canBeDisabled() ->children() diff --git a/src/Bundle/ChillPersonBundle/Entity/Identifier/PersonIdentifier.php b/src/Bundle/ChillPersonBundle/Entity/Identifier/PersonIdentifier.php new file mode 100644 index 000000000..f0dea00b4 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Entity/Identifier/PersonIdentifier.php @@ -0,0 +1,83 @@ + '[]', 'jsonb' => true])] + private array $value = []; + + #[ORM\Column(name: 'canonical', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => '[]'])] + private string $canonical = ''; + + public function __construct( + #[ORM\ManyToOne(targetEntity: PersonIdentifierDefinition::class)] + #[ORM\JoinColumn(name: 'definition_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] + private PersonIdentifierDefinition $definition, + ) {} + + public function getId(): ?int + { + return $this->id; + } + + public function setPerson(?Person $person): self + { + $this->person = $person; + + return $this; + } + + public function getPerson(): Person + { + return $this->person; + } + + public function getValue(): array + { + return $this->value; + } + + public function setValue(array $value): void + { + $this->value = $value; + } + + public function getCanonical(): string + { + return $this->canonical; + } + + public function setCanonical(string $canonical): void + { + $this->canonical = $canonical; + } + + public function getDefinition(): PersonIdentifierDefinition + { + return $this->definition; + } +} diff --git a/src/Bundle/ChillPersonBundle/Entity/Identifier/PersonIdentifierDefinition.php b/src/Bundle/ChillPersonBundle/Entity/Identifier/PersonIdentifierDefinition.php new file mode 100644 index 000000000..6d6112569 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Entity/Identifier/PersonIdentifierDefinition.php @@ -0,0 +1,107 @@ + true])] + private bool $active = true; + + public function __construct( + #[ORM\Column(name: 'label', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])] + private array $label, + #[ORM\Column(name: 'engine', type: \Doctrine\DBAL\Types\Types::STRING, length: 100)] + private string $engine, + #[ORM\Column(name: 'is_searchable', type: \Doctrine\DBAL\Types\Types::BOOLEAN, options: ['default' => false])] + private bool $isSearchable = false, + #[ORM\Column(name: 'is_editable_by_users', type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => false])] + private bool $isEditableByUsers = false, + #[ORM\Column(name: 'data', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])] + private array $data = [], + ) {} + + public function getId(): ?int + { + return $this->id; + } + + public function getLabel(): array + { + return $this->label; + } + + public function setLabel(array $label): void + { + $this->label = $label; + } + + public function getEngine(): string + { + return $this->engine; + } + + public function setEngine(string $engine): void + { + $this->engine = $engine; + } + + public function isSearchable(): bool + { + return $this->isSearchable; + } + + public function setIsSearchable(bool $isSearchable): void + { + $this->isSearchable = $isSearchable; + } + + public function isEditableByUsers(): bool + { + return $this->isEditableByUsers; + } + + public function setIsEditableByUsers(bool $isEditableByUsers): void + { + $this->isEditableByUsers = $isEditableByUsers; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): self + { + $this->active = $active; + + return $this; + } + + public function getData(): array + { + return $this->data; + } + + public function setData(array $data): void + { + $this->data = $data; + } +} diff --git a/src/Bundle/ChillPersonBundle/Entity/Person.php b/src/Bundle/ChillPersonBundle/Entity/Person.php index 5d57f11af..aa78774b6 100644 --- a/src/Bundle/ChillPersonBundle/Entity/Person.php +++ b/src/Bundle/ChillPersonBundle/Entity/Person.php @@ -31,6 +31,7 @@ use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; use Chill\PersonBundle\Entity\Household\Household; use Chill\PersonBundle\Entity\Household\HouseholdMember; use Chill\PersonBundle\Entity\Household\PersonHouseholdAddress; +use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; use Chill\PersonBundle\Entity\Person\PersonCenterCurrent; use Chill\PersonBundle\Entity\Person\PersonCenterHistory; use Chill\PersonBundle\Entity\Person\PersonCurrentAddress; @@ -271,6 +272,9 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI #[ORM\GeneratedValue(strategy: 'AUTO')] private ?int $id = null; + #[ORM\OneToMany(mappedBy: 'person', targetEntity: PersonIdentifier::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $identifiers; + /** * The person's last name. */ @@ -418,6 +422,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI $this->resources = new ArrayCollection(); $this->centerHistory = new ArrayCollection(); $this->signatures = new ArrayCollection(); + $this->identifiers = new ArrayCollection(); } public function __toString(): string @@ -498,6 +503,24 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI return $this; } + public function addIdentifier(PersonIdentifier $identifier): self + { + if (!$this->identifiers->contains($identifier)) { + $this->identifiers[] = $identifier; + $identifier->setPerson($this); + } + + return $this; + } + + public function removeIdentifier(PersonIdentifier $identifier): self + { + $this->identifiers->removeElement($identifier); + $identifier->setPerson(null); + + return $this; + } + public function removeSignature(EntityWorkflowStepSignature $signature): self { $this->signatures->removeElement($signature); @@ -1129,6 +1152,14 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI return $this->id; } + /** + * @return ReadableCollection + */ + public function getIdentifiers(): ReadableCollection + { + return $this->identifiers; + } + /** * @return string */ @@ -1262,6 +1293,22 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI return $this->spokenLanguages; } + public function addSpokenLanguage(Language $language): self + { + if (!$this->spokenLanguages->contains($language)) { + $this->spokenLanguages->add($language); + } + + return $this; + } + + public function removeSpokenLanguage(Language $language): self + { + $this->spokenLanguages->removeElement($language); + + return $this; + } + public function getUpdatedAt(): ?\DateTimeInterface { return $this->updatedAt; diff --git a/src/Bundle/ChillPersonBundle/Form/DataMapper/PersonIdentifiersDataMapper.php b/src/Bundle/ChillPersonBundle/Form/DataMapper/PersonIdentifiersDataMapper.php new file mode 100644 index 000000000..eea151865 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Form/DataMapper/PersonIdentifiersDataMapper.php @@ -0,0 +1,73 @@ + $formsByKey */ + $formsByKey = iterator_to_array($forms); + + foreach ($this->identifierManager->getWorkers() as $worker) { + if (!$worker->getDefinition()->isEditableByUsers()) { + continue; + } + $form = $formsByKey['identifier_'.$worker->getDefinition()->getId()]; + $identifier = $viewData->findFirst(fn (int $key, PersonIdentifier $identifier) => $worker->getDefinition()->getId() === $identifier->getId()); + if (null === $identifier) { + $identifier = new PersonIdentifier($worker->getDefinition()); + } + $form->setData($identifier->getValue()); + } + } + + public function mapFormsToData(\Traversable $forms, &$viewData): void + { + if (!$viewData instanceof Collection) { + throw new UnexpectedTypeException($viewData, Collection::class); + } + + foreach ($forms as $name => $form) { + $identifierId = (int) substr((string) $name, 11); + $identifier = $viewData->findFirst(fn (int $key, PersonIdentifier $identifier) => $identifier->getId() === $identifierId); + $definition = $this->identifierDefinitionRepository->find($identifierId); + if (null === $identifier) { + $identifier = new PersonIdentifier($definition); + $viewData->add($identifier); + } + if (!$identifier->getDefinition()->isEditableByUsers()) { + continue; + } + + $worker = $this->identifierManager->buildWorkerByPersonIdentifierDefinition($definition); + $identifier->setValue($form->getData()); + $identifier->setCanonical($worker->canonicalizeValue($identifier->getValue())); + } + } +} diff --git a/src/Bundle/ChillPersonBundle/Form/PersonIdentifiersType.php b/src/Bundle/ChillPersonBundle/Form/PersonIdentifiersType.php new file mode 100644 index 000000000..ea077f626 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Form/PersonIdentifiersType.php @@ -0,0 +1,48 @@ +identifierManager->getWorkers() as $worker) { + if (!$worker->getDefinition()->isEditableByUsers()) { + continue; + } + + $subBuilder = $builder->create( + 'identifier_'.$worker->getDefinition()->getId(), + options: [ + 'compound' => true, + 'label' => $this->translatableStringHelper->localize($worker->getDefinition()->getLabel()), + ] + ); + $worker->buildForm($subBuilder); + $builder->add($subBuilder); + } + + $builder->setDataMapper($this->identifiersDataMapper); + } +} diff --git a/src/Bundle/ChillPersonBundle/Form/PersonType.php b/src/Bundle/ChillPersonBundle/Form/PersonType.php index 21d56dde7..09ab04b01 100644 --- a/src/Bundle/ChillPersonBundle/Form/PersonType.php +++ b/src/Bundle/ChillPersonBundle/Form/PersonType.php @@ -72,8 +72,8 @@ class PersonType extends AbstractType public function buildForm(FormBuilderInterface $builder, array $options) { $builder - ->add('firstName') - ->add('lastName') + ->add('firstName', TextType::class, ['empty_data' => '']) + ->add('lastName', TextType::class, ['empty_data' => '']) ->add('birthdate', ChillDateType::class, [ 'required' => false, ]) @@ -101,7 +101,7 @@ class PersonType extends AbstractType if ('visible' === $this->config['memo']) { $builder - ->add('memo', ChillTextareaType::class, ['required' => false]); + ->add('memo', ChillTextareaType::class, ['required' => false, 'empty_data' => '']); } if ('visible' === $this->config['employment_status']) { @@ -118,6 +118,7 @@ class PersonType extends AbstractType $builder->add('placeOfBirth', TextType::class, [ 'required' => false, 'attr' => ['style' => 'text-transform: uppercase;'], + 'empty_data' => '', ]); $builder->get('placeOfBirth')->addModelTransformer(new CallbackTransformer( @@ -127,7 +128,9 @@ class PersonType extends AbstractType } if ('visible' === $this->config['contact_info']) { - $builder->add('contactInfo', ChillTextareaType::class, ['required' => false]); + $builder->add('contactInfo', ChillTextareaType::class, [ + 'required' => false, 'empty_data' => '', 'label' => 'Notes on contact information', + ]); } if ('visible' === $this->config['phonenumber']) { @@ -152,12 +155,12 @@ class PersonType extends AbstractType 'required' => false, ] ) - ->add('acceptSMS', CheckboxType::class, [ + ->add('acceptSms', CheckboxType::class, [ 'required' => false, ]); } - $builder->add('otherPhoneNumbers', ChillCollectionType::class, [ + $builder->add('otherPhonenumbers', ChillCollectionType::class, [ 'entry_type' => PersonPhoneType::class, 'button_add_label' => 'Add new phone', 'button_remove_label' => 'Remove phone', @@ -173,12 +176,12 @@ class PersonType extends AbstractType if ('visible' === $this->config['email']) { $builder - ->add('email', EmailType::class, ['required' => false]); + ->add('email', EmailType::class, ['required' => false, 'empty_data' => '']); } if ('visible' === $this->config['acceptEmail']) { $builder - ->add('acceptEmail', CheckboxType::class, ['required' => false]); + ->add('acceptEmail', CheckboxType::class, ['required' => false, 'empty_data' => '']); } if ('visible' === $this->config['country_of_birth']) { @@ -222,6 +225,10 @@ class PersonType extends AbstractType ]); } + $builder->add('identifiers', PersonIdentifiersType::class, [ + 'by_reference' => false, + ]); + if ($options['cFGroup']) { $builder ->add( @@ -232,10 +239,7 @@ class PersonType extends AbstractType } } - /** - * @param OptionsResolverInterface $resolver - */ - public function configureOptions(OptionsResolver $resolver) + public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => Person::class, @@ -251,10 +255,7 @@ class PersonType extends AbstractType ); } - /** - * @return string - */ - public function getBlockPrefix() + public function getBlockPrefix(): string { return 'chill_personbundle_person'; } diff --git a/src/Bundle/ChillPersonBundle/Notification/FlagProviders/DesignatedReferrerNotificationFlagProvider.php b/src/Bundle/ChillPersonBundle/Notification/FlagProviders/DesignatedReferrerNotificationFlagProvider.php new file mode 100644 index 000000000..df92e58aa --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Notification/FlagProviders/DesignatedReferrerNotificationFlagProvider.php @@ -0,0 +1,31 @@ +add('content', TextType::class, ['label' => false]); + } + + public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string + { + return $identifier?->getValue()['content'] ?? ''; + } +} diff --git a/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierEngineInterface.php b/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierEngineInterface.php new file mode 100644 index 000000000..6c75f263e --- /dev/null +++ b/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierEngineInterface.php @@ -0,0 +1,27 @@ + + */ + public function getWorkers(): array + { + $workers = []; + foreach ($this->personIdentifierDefinitionRepository->findByActive() as $definition) { + try { + $worker = $this->getEngine($definition->getEngine()); + } catch (EngineNotFoundException) { + continue; + } + + $workers[] = new PersonIdentifierWorker($worker, $definition); + + } + + return $workers; + } + + public function buildWorkerByPersonIdentifierDefinition(PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker + { + return new PersonIdentifierWorker($this->getEngine($personIdentifierDefinition->getEngine()), $personIdentifierDefinition); + } + + /** + * @throw EngineNotFoundException + */ + private function getEngine(string $name): PersonIdentifierEngineInterface + { + foreach ($this->engines as $engine) { + if ($engine->getName() === $name) { + return $engine; + } + } + + throw new EngineNotFoundException($name); + } +} diff --git a/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierManagerInterface.php b/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierManagerInterface.php new file mode 100644 index 000000000..9bec7d1fd --- /dev/null +++ b/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierManagerInterface.php @@ -0,0 +1,26 @@ + + */ + public function getWorkers(): array; + + public function buildWorkerByPersonIdentifierDefinition(PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker; +} diff --git a/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierWorker.php b/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierWorker.php new file mode 100644 index 000000000..94702b8eb --- /dev/null +++ b/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierWorker.php @@ -0,0 +1,49 @@ +identifierEngine; + } + + public function getDefinition(): PersonIdentifierDefinition + { + return $this->definition; + } + + public function buildForm(FormBuilderInterface $builder): void + { + $this->identifierEngine->buildForm($builder, $this->definition); + } + + public function canonicalizeValue(array $value): ?string + { + return $this->identifierEngine->canonicalizeValue($value, $this->definition); + } + + public function renderAsString(?PersonIdentifier $identifier): string + { + return $this->identifierEngine->renderAsString($identifier, $this->definition); + } +} diff --git a/src/Bundle/ChillPersonBundle/PersonIdentifier/Rendering/PersonIdRendering.php b/src/Bundle/ChillPersonBundle/PersonIdentifier/Rendering/PersonIdRendering.php new file mode 100644 index 000000000..c529b3aba --- /dev/null +++ b/src/Bundle/ChillPersonBundle/PersonIdentifier/Rendering/PersonIdRendering.php @@ -0,0 +1,65 @@ +idContentText = $parameterBag->get('chill_person')['person_render']['id_content_text']; + } + + public function renderPersonId(Person $person): string + { + $args = [ + '[[ person_id ]]' => $person->getId(), + ]; + + foreach ($person->getIdentifiers() as $identifier) { + if (!$identifier->getDefinition()->isActive()) { + continue; + } + + $key = 'identifier_'.$identifier->getDefinition()->getId(); + + $args + += [ + "[[ {$key} ]]" => $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($identifier->getDefinition()) + ->renderAsString($identifier), + "[[ if:{$key} ]]" => '', + "[[ endif:{$key} ]]" => '', + ]; + // we remove the eventual conditions + + + } + + $rendered = strtr($this->idContentText, $args); + + // Delete the conditions which are not met, for instance: + // [[ if:identifier_99 ]] ... [[ endif:identifier_99 ]] + // this match the same dumber for opening and closing of the condition + return preg_replace( + '/\[\[\s*if:identifier_(\d+)\s*\]\].*?\[\[\s*endif:identifier_\1\s*\]\]/s', + '', + $rendered + ); + } +} diff --git a/src/Bundle/ChillPersonBundle/PersonIdentifier/Rendering/PersonIdRenderingInterface.php b/src/Bundle/ChillPersonBundle/PersonIdentifier/Rendering/PersonIdRenderingInterface.php new file mode 100644 index 000000000..831dcc57d --- /dev/null +++ b/src/Bundle/ChillPersonBundle/PersonIdentifier/Rendering/PersonIdRenderingInterface.php @@ -0,0 +1,19 @@ + $this->personIdRendering->renderPersonId($person) + ), + ]; + } +} diff --git a/src/Bundle/ChillPersonBundle/PersonIdentifier/Rendering/PersonIdentifierEntityRender.php b/src/Bundle/ChillPersonBundle/PersonIdentifier/Rendering/PersonIdentifierEntityRender.php new file mode 100644 index 000000000..e84941ffd --- /dev/null +++ b/src/Bundle/ChillPersonBundle/PersonIdentifier/Rendering/PersonIdentifierEntityRender.php @@ -0,0 +1,41 @@ + + */ +final readonly class PersonIdentifierEntityRender implements ChillEntityRenderInterface +{ + public function __construct(private PersonIdentifierManagerInterface $identifierManager) {} + + public function renderBox(mixed $entity, array $options): string + { + return $this->renderString($entity, $options); + } + + public function renderString(mixed $entity, array $options): string + { + $worker = $this->identifierManager->buildWorkerByPersonIdentifierDefinition($entity->getDefinition()); + + return $worker->renderAsString($entity); + } + + public function supports(object $entity, array $options): bool + { + return $entity instanceof PersonIdentifier; + } +} diff --git a/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodWorkRepository.php b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodWorkRepository.php index 0d5bf5eee..df5ee6c66 100644 --- a/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodWorkRepository.php +++ b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodWorkRepository.php @@ -90,7 +90,7 @@ class AccompanyingPeriodWorkRepository implements ObjectRepository * * first, opened works * * then, closed works * - * @param array{types?: list, user?: list, after?: \DateTimeImmutable|null, before?: \DateTimeImmutable|null} $filters + * @param array{types?: list, user?: list, currentUser?: User, after?: \DateTimeImmutable|null, before?: \DateTimeImmutable|null} $filters * * @return AccompanyingPeriodWork[] */ @@ -101,6 +101,7 @@ class AccompanyingPeriodWorkRepository implements ObjectRepository $sql = "SELECT {$rsm} FROM chill_person_accompanying_period_work w LEFT JOIN chill_person_accompanying_period_work_referrer AS rw ON accompanyingperiodwork_id = w.id + AND (rw.enddate IS NULL OR rw.enddate > CURRENT_DATE) WHERE accompanyingPeriod_id = :periodId"; // implement filters @@ -119,6 +120,10 @@ class AccompanyingPeriodWorkRepository implements ObjectRepository .')'; } + if (isset($filters['currentUser'])) { + $sql .= ' AND rw.user_id = :currentUser'; + } + $sql .= " AND daterange(:after::date, :before::date) && daterange(w.startDate, w.endDate, '[]')"; // if the start and end date were inversed, we inverse the order to avoid an error @@ -152,6 +157,11 @@ class AccompanyingPeriodWorkRepository implements ObjectRepository ->setParameter('limit', $limit, Types::INTEGER) ->setParameter('offset', $offset, Types::INTEGER); + if (isset($filters['currentUser'])) { + $nq->setParameter('currentUser', $filters['currentUser']->getId()); + } + + foreach ($filters['user'] as $key => $user) { $nq->setParameter('user_'.$key, $user); } diff --git a/src/Bundle/ChillPersonBundle/Repository/Identifier/PersonIdentifierDefinitionRepository.php b/src/Bundle/ChillPersonBundle/Repository/Identifier/PersonIdentifierDefinitionRepository.php new file mode 100644 index 000000000..33b531e5a --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Repository/Identifier/PersonIdentifierDefinitionRepository.php @@ -0,0 +1,32 @@ + + */ +class PersonIdentifierDefinitionRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $managerRegistry) + { + parent::__construct($managerRegistry, PersonIdentifierDefinition::class); + } + + public function findByActive(): array + { + return $this->findBy(['active' => true]); + } +} diff --git a/src/Bundle/ChillPersonBundle/Resources/public/chill/chillperson.scss b/src/Bundle/ChillPersonBundle/Resources/public/chill/chillperson.scss index afa163cf2..846ba008e 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/chill/chillperson.scss +++ b/src/Bundle/ChillPersonBundle/Resources/public/chill/chillperson.scss @@ -281,11 +281,6 @@ abbr.referrer { // still used ? font-style: italic; } -.created-updated { - border: 1px solid black; - padding: 10px; -} - /// Masonry blocs on AccompanyingCourse resume page div#dashboards { div.mbloc { diff --git a/src/Bundle/ChillPersonBundle/Resources/public/chill/scss/render_box.scss b/src/Bundle/ChillPersonBundle/Resources/public/chill/scss/render_box.scss index 230640bbd..6ec463f77 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/chill/scss/render_box.scss +++ b/src/Bundle/ChillPersonBundle/Resources/public/chill/scss/render_box.scss @@ -10,7 +10,8 @@ /// SOCIAL-ISSUE AND SOCIAL-ACTION &.entity-social-issue, - &.entity-social-action { + &.entity-social-action, + &.entity-event-theme { margin-right: 0.3em; font-size: 120%; span.badge { @@ -32,4 +33,9 @@ @include badge_social($social-action-color); } } + &.entity-event-theme { + span.badge { + @include badge_social($event-theme-color); + } + } } diff --git a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourse/banner.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourse/banner.html.twig index bb039b5a0..fb1a81dfe 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourse/banner.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourse/banner.html.twig @@ -8,7 +8,7 @@

      {{ 'Accompanying Course'|trans }} - {{ accompanyingCourse.id }} + ({{ 'accompanying_period.number'|trans({ 'id': accompanyingCourse.id}) }})

      diff --git a/src/Bundle/ChillPersonBundle/Resources/views/Entity/person.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/Entity/person.html.twig index 380a17fa2..d8a930b40 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/Entity/person.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/Entity/person.html.twig @@ -78,11 +78,6 @@ {%- if options['addEntity'] -%} {{ 'Person'|trans }} {%- endif -%} - {%- if options['addId'] -%} - - {{ person.id|upper -}} - - {%- endif -%}
      {%- if options['addInfo'] -%}

      @@ -99,6 +94,12 @@ {%- if options['addAge'] -%}  {{ 'years_old'|trans({ 'age': person.age }) }} {%- endif -%} + {%- if options['addId'] -%} + {%- set personId = person|chill_person_id_render_text %} + + ({{ personId }}) + + {%- endif -%} {%- elseif person.birthdate is not null -%}

      {%- endif -%} {#- tricks to remove easily whitespace after template -#} diff --git a/src/Bundle/ChillPersonBundle/Resources/views/Person/edit.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/Person/edit.html.twig index cb2d867c4..fe5dde242 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/Person/edit.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/Person/edit.html.twig @@ -31,7 +31,7 @@ {% if form.memo is defined %}

      {{ 'Memo'|trans }}

      - {{ form_row(form.memo, {'label' : 'Memo'} ) }} + {{ form_widget(form.memo, {'label' : 'Memo'} ) }}
      {% endif %} @@ -85,15 +85,17 @@ {{ form_row(form.mobilenumber, {'label': 'Mobilenumber'}) }}
      - {{ form_row(form.acceptSMS, {'label' : 'Accept short text message ?'}) }} + {{ form_row(form.acceptSms, {'label' : 'Accept short text message ?'}) }}
      {%- endif -%} - {%- if form.otherPhoneNumbers is defined -%} - {{ form_widget(form.otherPhoneNumbers) }} - {{ form_errors(form.otherPhoneNumbers) }} + {%- if form.otherPhonenumbers is defined -%} + {{ form_widget(form.otherPhonenumbers) }} + {{ form_errors(form.otherPhonenumbers) }} {%- endif -%} {%- if form.contactInfo is defined -%} - {{ form_row(form.contactInfo, {'label': 'Notes on contact information'}) }} + {{ form_label(form.contactInfo) }} + {{ form_widget(form.contactInfo) }} + {{ form_errors(form.contactInfo) }} {%- endif -%} {%- endif -%} @@ -134,6 +136,20 @@ {%- endif -%} + {% if form.identifiers|length > 0 %} +
      +

      {{ 'person.Identifiers'|trans }}

      +
      + {% for f in form.identifiers %} + {{ form_row(f) }} + {% endfor %} +
      +
      + {% else %} + {{ form_widget(form.identifiers) }} + {% endif %} + + {{ form_rest(form) }}
        diff --git a/src/Bundle/ChillPersonBundle/Resources/views/Person/list_with_period.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/Person/list_with_period.html.twig index 41bc51864..0840cecb6 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/Person/list_with_period.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/Person/list_with_period.html.twig @@ -32,9 +32,16 @@
        {% if app != null %} -
        - {{ 'Since %date%'|trans({'%date%': app.startDate|format_date('medium') }) }} -
        + {% if acp.closingDate != null %} + {{ 'accompanying_period.dates_from_%opening_date%_to_%closing_date%'|trans({ + '%opening_date%': acp.openingDate|format_date('long'), + '%closing_date%': acp.closingDate|format_date('long')} + ) }} + {% else %} +
        + {{ 'Since %date%'|trans({'%date%': app.startDate|format_date('medium') }) }} +
        + {% endif %} {% endif %} {% set notif_counter = chill_count_notifications('Chill\\PersonBundle\\Entity\\AccompanyingPeriod', acp.id) %} @@ -70,6 +77,20 @@
        + {% if acp.step == 'CLOSED' and acp.closingMotive is not null %} +
        +
        +

        {{ 'Closing motive'|trans }}

        +
        +
        +
        + {{ acp.closingMotive.name|localize_translatable_string }} +
        +
        +
        + {% endif %} + + {% if acp.user is not null %}
        diff --git a/src/Bundle/ChillPersonBundle/Resources/views/Person/view.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/Person/view.html.twig index c8f4da3ec..73a957383 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/Person/view.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/Person/view.html.twig @@ -1,19 +1,3 @@ -{# - * Copyright (C) 2014, Champs Libres Cooperative SCRLFS, - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . -#} {% extends "@ChillPerson/Person/layout.html.twig" %} {% set activeRouteKey = 'chill_person_view' %} @@ -78,6 +62,16 @@ This view should receive those arguments: {% else %}
        {{ 'gender.not defined'|trans }}
        {% endif %} + + {% if person.genderComment.comment is not empty %} +
        {{ 'Gender comment'|trans }} :
        +
        +
        + {{ person.genderComment.comment|chill_markdown_to_html }} +
        +
        + {% endif %} +
    @@ -126,16 +120,6 @@ This view should receive those arguments: - {% if person.genderComment.comment is not empty %} -
    -
    -

    {{ 'Gender comment'|trans }} :

    -
    - {{ person.genderComment.comment|chill_markdown_to_html }} -
    -
    -
    - {% endif %}
    @@ -241,17 +225,20 @@ This view should receive those arguments: {{ 'No data given'|trans }} {% endif %} -
    {{ 'Comment on the marital status'|trans }} :
    - -
    - {% if person.maritalStatusComment.comment is not empty %} -
    - {{ person.maritalStatusComment.comment|chill_markdown_to_html }} -
    - {% else %} - {{ 'No data given'|trans }} + {% if person.maritalStatusComment.comment is not empty %} +
    {{ 'Comment on the marital status'|trans }} :
    +
    +
    + {{ person.maritalStatusComment.comment|chill_markdown_to_html }} +
    +
    + {% endif %} + {% for identifier in person.identifiers %} + {% if identifier.definition.isActive and (identifier|chill_entity_render_string) is not empty %} +
    {{ identifier.definition.label|localize_translatable_string }} :
    +
    {{ identifier|chill_entity_render_box }}
    {% endif %} - + {% endfor %} {%- endif -%} @@ -341,7 +328,7 @@ This view should receive those arguments:
    {% endif %} -
    +
    {% if person.createdBy %}
    {{ 'Created by'|trans}}: {{ person.createdBy|chill_entity_render_box({'at_date': person.createdAt}) }},
    diff --git a/src/Bundle/ChillPersonBundle/Service/AccompanyingPeriodWork/AccompanyingPeriodWorkMergeService.php b/src/Bundle/ChillPersonBundle/Service/AccompanyingPeriodWork/AccompanyingPeriodWorkMergeService.php index ba0f93ce3..83793fcd2 100644 --- a/src/Bundle/ChillPersonBundle/Service/AccompanyingPeriodWork/AccompanyingPeriodWorkMergeService.php +++ b/src/Bundle/ChillPersonBundle/Service/AccompanyingPeriodWork/AccompanyingPeriodWorkMergeService.php @@ -17,9 +17,9 @@ use Doctrine\ORM\EntityManagerInterface; /** * Service for merging two AccompanyingPeriodWork entities into a single entity. */ -class AccompanyingPeriodWorkMergeService +readonly class AccompanyingPeriodWorkMergeService { - public function __construct(private readonly EntityManagerInterface $em) {} + public function __construct(private EntityManagerInterface $em) {} /** * Merges two AccompanyingPeriodWork entities into one by transferring relevant data and removing the obsolete entity. @@ -35,8 +35,9 @@ class AccompanyingPeriodWorkMergeService $this->alterStartDate($toKeep, $toDelete); $this->alterEndDate($toKeep, $toDelete); $this->concatenateComments($toKeep, $toDelete); + $this->transferEvaluationsSQL($toKeep, $toDelete); $this->transferWorkflowsSQL($toKeep, $toDelete); - $this->updateReferencesSQL($toKeep, $toDelete); + $this->updateReferences($toKeep, $toDelete); $entityManager->remove($toDelete); }); @@ -54,6 +55,16 @@ class AccompanyingPeriodWorkMergeService ); } + private function transferEvaluationsSQL(AccompanyingPeriodWork $toKeep, AccompanyingPeriodWork $toDelete): void + { + $this->em->getConnection()->executeQuery( + 'UPDATE chill_person_accompanying_period_work_evaluation cpapwe + SET accompanyingperiodwork_id = :toKeepId + WHERE cpapwe.accompanyingperiodwork_id = :toDeleteId', + ['toKeepId' => $toKeep->getId(), 'toDeleteId' => $toDelete->getId()] + ); + } + private function alterStartDate(AccompanyingPeriodWork $toKeep, AccompanyingPeriodWork $toDelete): void { $startDate = min($toKeep->getStartDate(), $toDelete->getStartDate()); @@ -74,16 +85,17 @@ class AccompanyingPeriodWorkMergeService private function concatenateComments(AccompanyingPeriodWork $toKeep, AccompanyingPeriodWork $toDelete): void { - $toKeep->setNote($toKeep->getNote()."\n\n-----------------\n\n".$toDelete->getNote()); - $toKeep->getPrivateComment()->concatenateComments($toDelete->getPrivateComment()); - } - - private function updateReferencesSQL(AccompanyingPeriodWork $toKeep, AccompanyingPeriodWork $toDelete): void - { - foreach ($toDelete->getAccompanyingPeriodWorkEvaluations() as $evaluation) { - $toKeep->addAccompanyingPeriodWorkEvaluation($evaluation); + if ('' !== $toDelete->getNote()) { + $toKeep->setNote($toKeep->getNote()."\n\n-----------------\n\n".$toDelete->getNote()); } + if (count($toDelete->getPrivateComment()->getComments()) > 0) { + $toKeep->getPrivateComment()->concatenateComments($toDelete->getPrivateComment()); + } + } + + private function updateReferences(AccompanyingPeriodWork $toKeep, AccompanyingPeriodWork $toDelete): void + { foreach ($toDelete->getReferrers() as $referrer) { // we only keep the current referrer $toKeep->addReferrer($referrer); diff --git a/src/Bundle/ChillPersonBundle/Templating/Entity/PersonRender.php b/src/Bundle/ChillPersonBundle/Templating/Entity/PersonRender.php index a09ad11f5..f74aa9ec1 100644 --- a/src/Bundle/ChillPersonBundle/Templating/Entity/PersonRender.php +++ b/src/Bundle/ChillPersonBundle/Templating/Entity/PersonRender.php @@ -23,7 +23,11 @@ class PersonRender implements PersonRenderInterface { use BoxUtilsChillEntityRenderTrait; - public function __construct(private readonly ConfigPersonAltNamesHelper $configAltNamesHelper, private readonly \Twig\Environment $engine, private readonly TranslatorInterface $translator) {} + public function __construct( + private readonly ConfigPersonAltNamesHelper $configAltNamesHelper, + private readonly \Twig\Environment $engine, + private readonly TranslatorInterface $translator, + ) {} public function renderBox($person, array $options): string { diff --git a/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Rendering/PersonIdRenderingTest.php b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Rendering/PersonIdRenderingTest.php new file mode 100644 index 000000000..ce1df3f9d --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Rendering/PersonIdRenderingTest.php @@ -0,0 +1,145 @@ +prophesize(ParameterBagInterface::class); + $parameterBag->get('chill_person') + ->willReturn(['person_render' => ['id_content_text' => $idContentText]]); + + // PersonIdentifierManager is explicitly requested to be mocked in the spec. + // It will return a PersonIdentifierWorker whose renderAsString behaves like StringIdentifier::renderAsString + $personIdentifierManager = $this->prophesize(PersonIdentifierManagerInterface::class); + $personIdentifierManager + ->buildWorkerByPersonIdentifierDefinition(Argument::type(PersonIdentifierDefinition::class)) + ->will(function ($args) { + /** @var PersonIdentifierDefinition $definition */ + $definition = $args[0]; + + $engine = new class () implements PersonIdentifierEngineInterface { + public static function getName(): string + { + return 'test'; + } + + public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string + { + return $value['content'] ?? ''; + } + + public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {} + + public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string + { + // same behavior as StringIdentifier::renderAsString + return $identifier?->getValue()['content'] ?? ''; + } + }; + + return new PersonIdentifierWorker($engine, $definition); + }); + + $service = new PersonIdRendering($parameterBag->reveal(), $personIdentifierManager->reveal()); + + self::assertSame($expected, $service->renderPersonId($person)); + } + + public function provideRenderCases(): iterable + { + // Case 1: one active identifier, one inactive identifier, should render person id and only active identifier + $person1 = new Person(); + $this->setEntityId($person1, 123); + + $defActive = new PersonIdentifierDefinition(label: ['en' => 'Active'], engine: 'string'); + $this->setEntityId($defActive, 10); + $defActive->setActive(true); + + $idActive = new PersonIdentifier($defActive); + $idActive->setPerson($person1); + $idActive->setValue(['content' => 'ABC']); + $person1->addIdentifier($idActive); + + $defInactive = new PersonIdentifierDefinition(label: ['en' => 'Inactive'], engine: 'string'); + $this->setEntityId($defInactive, 99); + $defInactive->setActive(false); + + $idInactive = new PersonIdentifier($defInactive); + $idInactive->setPerson($person1); + $idInactive->setValue(['content' => 'SHOULD_NOT_APPEAR']); + $person1->addIdentifier($idInactive); + + $template1 = 'ID: [[ person_id ]] - Active: [[ identifier_10 ]] - Inactive: [[ identifier_99 ]]'; + $expected1 = 'ID: 123 - Active: ABC - Inactive: [[ identifier_99 ]]'; + + yield + 'with active and inactive identifiers' => [$person1, $template1, $expected1] + ; + + $template2 = 'ID: [[ person_id ]][[ if:identifier_10 ]] - Active: [[ identifier_10 ]][[ endif:identifier_10 ]]'; + $expected2 = 'ID: 123 - Active: ABC'; + + yield + 'rendering with conditional: condition are removed' => [$person1, $template2, $expected2] + ; + + $template3 = 'ID: [[ person_id ]][[ if:identifier_99 ]] - Inactive: [[ identifier_10 ]][[ endif:identifier_99 ]]'; + $expected3 = 'ID: 123'; + + yield + 'rendering with conditional: the content between condition is removed' => [$person1, $template3, $expected3] + ; + + $template4 = 'ID: [[ person_id ]][[ if:identifier_105 ]] - not present: [[ identifier_105 ]][[ endif:identifier_105 ]]'; + $expected4 = 'ID: 123'; + + yield + 'rendering with conditional: the content between condition is removed, the identifier is not associated with the person' => [$person1, $template4, $expected4] + ; + + } + + private function setEntityId(object $entity, int $id): void + { + $refl = new \ReflectionClass($entity); + $prop = $refl->getProperty('id'); + $prop->setAccessible(true); + $prop->setValue($entity, $id); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/Service/AccompanyingPeriodWork/AccompanyingPeriodWorkMergeServiceTest.php b/src/Bundle/ChillPersonBundle/Tests/Service/AccompanyingPeriodWork/AccompanyingPeriodWorkMergeServiceTest.php index e69c8beee..0ce941f01 100644 --- a/src/Bundle/ChillPersonBundle/Tests/Service/AccompanyingPeriodWork/AccompanyingPeriodWorkMergeServiceTest.php +++ b/src/Bundle/ChillPersonBundle/Tests/Service/AccompanyingPeriodWork/AccompanyingPeriodWorkMergeServiceTest.php @@ -14,22 +14,20 @@ namespace Chill\PersonBundle\Tests\Service\AccompanyingPeriodWork; use Chill\MainBundle\Entity\User; use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork; use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation; -use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkGoal; -use Chill\PersonBundle\Entity\SocialWork\Result; use Chill\PersonBundle\Service\AccompanyingPeriodWork\AccompanyingPeriodWorkMergeService; use Chill\ThirdPartyBundle\Entity\ThirdParty; use Doctrine\DBAL\Connection; use Doctrine\ORM\EntityManagerInterface; -use Monolog\Test\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; /** * @internal * * @coversNothing */ -class AccompanyingPeriodWorkMergeServiceTest extends TestCase +class AccompanyingPeriodWorkMergeServiceTest extends KernelTestCase { use ProphecyTrait; @@ -160,46 +158,62 @@ class AccompanyingPeriodWorkMergeServiceTest extends TestCase ]; } - public function testMerge(): void + public function testMergeAccompanyingPeriodWorks(): void { - $accompanyingPeriodWork = new AccompanyingPeriodWork(); - $accompanyingPeriodWork->setStartDate(new \DateTime('2022-01-01')); - $accompanyingPeriodWork->addReferrer($userA = new User()); - $accompanyingPeriodWork->addReferrer($userC = new User()); - $accompanyingPeriodWork->addAccompanyingPeriodWorkEvaluation($evaluationA = new AccompanyingPeriodWorkEvaluation()); - $accompanyingPeriodWork->setNote('blabla'); - $accompanyingPeriodWork->addThirdParty($thirdPartyA = new ThirdParty()); + $em = self::getContainer()->get(EntityManagerInterface::class); + + $userA = new User(); + $userA->setUsername('someUser'); + $userA->setEmail('someUser@example.com'); + $em->persist($userA); + + $toKeep = new AccompanyingPeriodWork(); + $toKeep->setStartDate(new \DateTime('2022-01-02')); + $toKeep->setNote('Keep note'); + $toKeep->setCreatedBy($userA); + $toKeep->setUpdatedBy($userA); + $toKeep->addReferrer($userA); + $em->persist($toKeep); + + $userB = new User(); + $userB->setUsername('anotherUser'); + $userB->setEmail('anotherUser@example.com'); + $em->persist($userB); $toDelete = new AccompanyingPeriodWork(); $toDelete->setStartDate(new \DateTime('2022-01-01')); - $toDelete->addReferrer($userB = new User()); - $toDelete->addReferrer($userC); - $toDelete->addAccompanyingPeriodWorkEvaluation($evaluationB = new AccompanyingPeriodWorkEvaluation()); - $toDelete->setNote('boum'); - $toDelete->addThirdParty($thirdPartyB = new ThirdParty()); - $toDelete->addGoal($goalA = new AccompanyingPeriodWorkGoal()); - $toDelete->addResult($resultA = new Result()); + $toDelete->setNote('Delete note'); + $toDelete->setCreatedBy($userB); + $toDelete->setUpdatedBy($userB); + $toDelete->addReferrer($userB); + $em->persist($toDelete); - $service = $this->buildMergeService($toDelete); - $service->merge($accompanyingPeriodWork, $toDelete); + $evaluation = new AccompanyingPeriodWorkEvaluation(); + $evaluation->setAccompanyingPeriodWork($toDelete); + $em->persist($evaluation); - self::assertTrue($accompanyingPeriodWork->getReferrers()->contains($userA)); - self::assertTrue($accompanyingPeriodWork->getReferrers()->contains($userB)); - self::assertTrue($accompanyingPeriodWork->getReferrers()->contains($userC)); + $em->flush(); - self::assertTrue($accompanyingPeriodWork->getAccompanyingPeriodWorkEvaluations()->contains($evaluationA)); - self::assertTrue($accompanyingPeriodWork->getAccompanyingPeriodWorkEvaluations()->contains($evaluationB)); - foreach ($accompanyingPeriodWork->getAccompanyingPeriodWorkEvaluations() as $evaluation) { - self::assertSame($accompanyingPeriodWork, $evaluation->getAccompanyingPeriodWork()); - } + $service = new AccompanyingPeriodWorkMergeService($em); + $merged = $service->merge($toKeep, $toDelete); - self::assertStringContainsString('blabla', $accompanyingPeriodWork->getNote()); - self::assertStringContainsString('boum', $toDelete->getNote()); + $em->refresh($merged); - self::assertTrue($accompanyingPeriodWork->getThirdParties()->contains($thirdPartyA)); - self::assertTrue($accompanyingPeriodWork->getThirdParties()->contains($thirdPartyB)); + // Assertions - self::assertTrue($accompanyingPeriodWork->getGoals()->contains($goalA)); - self::assertTrue($accompanyingPeriodWork->getResults()->contains($resultA)); + $this->assertEquals(new \DateTime('2022-01-01'), $merged->getStartDate()); + + $this->assertStringContainsString('Keep note', $merged->getNote()); + $this->assertStringContainsString('Delete note', $merged->getNote()); + + $em->refresh($evaluation); + $this->assertEquals($toKeep->getId(), $evaluation->getAccompanyingPeriodWork()->getId()); + + $em->remove($evaluation); + $em->remove($toKeep); + $em->remove($toDelete); + $em->remove($userA); + $em->remove($userB); + $em->flush(); } } diff --git a/src/Bundle/ChillPersonBundle/config/services.yaml b/src/Bundle/ChillPersonBundle/config/services.yaml index 526f5cbbd..1b57721ff 100644 --- a/src/Bundle/ChillPersonBundle/config/services.yaml +++ b/src/Bundle/ChillPersonBundle/config/services.yaml @@ -95,3 +95,16 @@ services: Chill\PersonBundle\Service\EntityInfo\AccompanyingPeriodViewEntityInfoProvider: arguments: $unions: !tagged_iterator chill_person.accompanying_period_info_part + + Chill\PersonBundle\PersonIdentifier\PersonIdentifierManager: + arguments: + $engines: !tagged_iterator chill_person.person_identifier_engine + + Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface: + alias: Chill\PersonBundle\PersonIdentifier\PersonIdentifierManager + + Chill\PersonBundle\PersonIdentifier\Identifier\: + resource: '../PersonIdentifier/Identifier' + + Chill\PersonBundle\PersonIdentifier\Rendering\: + resource: '../PersonIdentifier/Rendering' diff --git a/src/Bundle/ChillPersonBundle/config/services/notification.yaml b/src/Bundle/ChillPersonBundle/config/services/notification.yaml index f5d227429..58dcce746 100644 --- a/src/Bundle/ChillPersonBundle/config/services/notification.yaml +++ b/src/Bundle/ChillPersonBundle/config/services/notification.yaml @@ -1,4 +1,8 @@ services: + _defaults: + autowire: true + autoconfigure: true + Chill\PersonBundle\Notification\AccompanyingPeriodNotificationHandler: autowire: true autoconfigure: true @@ -8,3 +12,5 @@ services: Chill\PersonBundle\Notification\AccompanyingPeriodWorkEvaluationDocumentNotificationHandler: autowire: true autoconfigure: true + Chill\PersonBundle\Notification\FlagProviders\DesignatedReferrerNotificationFlagProvider: ~ + Chill\PersonBundle\Notification\FlagProviders\PersonAddressMoveNotificationFlagProvider: ~ diff --git a/src/Bundle/ChillPersonBundle/migrations/Version20250822123819.php b/src/Bundle/ChillPersonBundle/migrations/Version20250822123819.php new file mode 100644 index 000000000..02ff0219e --- /dev/null +++ b/src/Bundle/ChillPersonBundle/migrations/Version20250822123819.php @@ -0,0 +1,68 @@ +addSql('CREATE SEQUENCE chill_person_identifier_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE chill_person_identifier_definition_id_seq INCREMENT BY 1 MINVALUE 1 START 1000'); + $this->addSql( + <<<'SQL' + CREATE TABLE chill_person_identifier ( + id INT NOT NULL, + person_id INT NOT NULL, + definition_id INT NOT NULL, + value JSONB NOT NULL DEFAULT '[]'::jsonb, + canonical TEXT NOT NULL DEFAULT '', + PRIMARY KEY(id) + ) + SQL + ); + $this->addSql('CREATE INDEX IDX_BCA5A36B217BBB47 ON chill_person_identifier (person_id)'); + $this->addSql('CREATE INDEX IDX_BCA5A36BD11EA911 ON chill_person_identifier (definition_id)'); + $this->addSql( + <<<'SQL' + CREATE TABLE chill_person_identifier_definition ( + id INT NOT NULL, + label JSON DEFAULT '[]' NOT NULL, + engine VARCHAR(100) NOT NULL, + is_searchable BOOLEAN DEFAULT false NOT NULL, + is_editable_by_users BOOLEAN DEFAULT false NOT NULL, + data JSONB DEFAULT '[]' NOT NULL, + active BOOLEAN DEFAULT true NOT NULL, + PRIMARY KEY(id)) + SQL + ); + $this->addSql('ALTER TABLE chill_person_identifier ADD CONSTRAINT FK_BCA5A36B217BBB47 FOREIGN KEY (person_id) REFERENCES chill_person_person (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_person_identifier ADD CONSTRAINT FK_BCA5A36BD11EA911 FOREIGN KEY (definition_id) REFERENCES chill_person_identifier_definition (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE chill_person_identifier_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE chill_person_identifier_definition_id_seq CASCADE'); + $this->addSql('ALTER TABLE chill_person_identifier DROP CONSTRAINT FK_BCA5A36B217BBB47'); + $this->addSql('ALTER TABLE chill_person_identifier DROP CONSTRAINT FK_BCA5A36BD11EA911'); + $this->addSql('DROP TABLE chill_person_identifier'); + $this->addSql('DROP TABLE chill_person_identifier_definition'); + } +} diff --git a/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml b/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml index ae7ac6284..c5e8e097c 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml +++ b/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml @@ -21,6 +21,9 @@ accompanying_period: other {Participants} } + number: >- + n° {id} + person: from_the: depuis le And himself: >- diff --git a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml index 09fe02f5e..adab35966 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml @@ -102,6 +102,9 @@ spokenLanguages: Langues parlées Employment status: Situation professionelle Administrative status: Situation administrative +person: + Identifiers: Identifiants + # dédoublonnage Old person: Doublon @@ -926,7 +929,7 @@ accompanying_course_work: types_filter: Filtrer par type d'action user_filter: Filtrer par intervenant On-going works over total: Actions en cours / Actions du parcours - + my_actions_filter: Mes actions (où j'interviens) # Person addresses: Adresses de résidence @@ -1513,6 +1516,7 @@ acpw_duplicate: to keep: Action d'accompagnement à conserver to delete: Action d'accompagnement à supprimer Successfully merged: Action d'accompagnement fusionnée avec succès. + You cannot merge a accompanying period work with itself. Please choose a different one: Vous ne pouvez pas fusionner un action d'accompagnement avec lui-même. Veuillez en choisir un autre. my_parcours_filters: referrer_parcours_and_acpw: Agent traitant ou réferent diff --git a/src/Bundle/ChillTaskBundle/Controller/SingleTaskController.php b/src/Bundle/ChillTaskBundle/Controller/SingleTaskController.php index 152184570..debf0f1be 100644 --- a/src/Bundle/ChillTaskBundle/Controller/SingleTaskController.php +++ b/src/Bundle/ChillTaskBundle/Controller/SingleTaskController.php @@ -624,8 +624,7 @@ final class SingleTaskController extends AbstractController ->addCheckbox('status', $statuses, $statuses, $statusTrans); $states = $this->singleTaskStateRepository->findAllExistingStates(); - $checked = array_values(array_filter($states, fn (string $state) => !in_array($state, ['in_progress', 'closed', 'canceled', 'validated'], true))); - + $checked = array_values(array_filter($states, fn (string $state) => !in_array($state, ['to_validate', 'in_progress', 'closed', 'canceled', 'validated'], true))); if ([] !== $states) { $filterBuilder ->addCheckbox('states', $states, $checked); diff --git a/src/Bundle/ChillThirdPartyBundle/Controller/ThirdpartyDuplicateController.php b/src/Bundle/ChillThirdPartyBundle/Controller/ThirdpartyDuplicateController.php new file mode 100644 index 000000000..ab42b5656 --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Controller/ThirdpartyDuplicateController.php @@ -0,0 +1,125 @@ +getKind()) { + $suggested = $thirdparty->getParent()->getChildren(); + } + + $form = $this->createForm(ThirdpartyFindDuplicateType::class, null, ['suggested' => $suggested]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $thirdparty2 = $form->get('thirdparty')->getData(); + + $direction = $form->get('direction')->getData(); + + if ('starting' === $direction) { + $params = [ + 'thirdparty1_id' => $thirdparty->getId(), + 'thirdparty2_id' => $thirdparty2->getId(), + ]; + } else { + $params = [ + 'thirdparty1_id' => $thirdparty2->getId(), + 'thirdparty2_id' => $thirdparty->getId(), + ]; + } + + return $this->redirectToRoute('chill_thirdparty_duplicate_confirm', $params); + } + + return $this->render('@ChillThirdParty/ThirdPartyDuplicate/find_duplicate.html.twig', [ + 'thirdparty' => $thirdparty, + 'form' => $form->createView(), + ]); + } + + /** + * @ParamConverter("thirdparty1", options={"id": "thirdparty1_id"}) + * @ParamConverter("thirdparty2", options={"id": "thirdparty2_id"}) + */ + #[Route(path: '/{_locale}/3party/{thirdparty1_id}/duplicate/{thirdparty2_id}/confirm', name: 'chill_thirdparty_duplicate_confirm')] + public function confirmAction(ThirdParty $thirdparty1, ThirdParty $thirdparty2, Request $request) + { + try { + $this->validateThirdpartyMerge($thirdparty1, $thirdparty2); + $form = $this->createForm(PersonConfimDuplicateType::class); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + + $this->thirdPartyMergeService->merge($thirdparty1, $thirdparty2); + + $session = $request->getSession(); + if ($session instanceof Session) { + $session->getFlashBag()->add('success', new TranslatableMessage('thirdparty_duplicate.Merge successful')); + } + + return $this->redirectToRoute('chill_crud_3party_3party_view', ['id' => $thirdparty1->getId()]); + } + + return $this->render('@ChillThirdParty/ThirdPartyDuplicate/confirm.html.twig', [ + 'thirdparty' => $thirdparty1, + 'thirdparty2' => $thirdparty2, + 'form' => $form->createView(), + ]); + } catch (\InvalidArgumentException $e) { + $this->addFlash('error', $this->translator->trans($e->getMessage())); + + return $this->redirectToRoute('chill_thirdparty_find_duplicate', [ + 'thirdparty_id' => $thirdparty1->getId(), + ]); + } + } + + private function validateThirdpartyMerge(ThirdParty $thirdparty1, ThirdParty $thirdparty2): void + { + $constraints = [ + [$thirdparty1 === $thirdparty2, 'thirdparty_duplicate.You cannot merge a thirdparty with itself. Please choose a different thirdparty'], + [$thirdparty1->getKind() !== $thirdparty2->getKind(), 'thirdparty_duplicate.A thirdparty can only be merged with a thirdparty of the same kind'], + [$thirdparty1->getParent() !== $thirdparty2->getParent(), 'thirdparty_duplicate.Two child thirdparties must have the same parent'], + ]; + + foreach ($constraints as [$condition, $message]) { + if ($condition) { + throw new \InvalidArgumentException($message); + } + } + } +} diff --git a/src/Bundle/ChillThirdPartyBundle/Form/ThirdpartyFindDuplicateType.php b/src/Bundle/ChillThirdPartyBundle/Form/ThirdpartyFindDuplicateType.php new file mode 100644 index 000000000..275b6d21c --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Form/ThirdpartyFindDuplicateType.php @@ -0,0 +1,41 @@ +add('thirdparty', PickThirdpartyDynamicType::class, [ + 'label' => 'Find duplicate', + 'mapped' => false, + 'suggested' => $options['suggested'], + ]) + ->add('direction', HiddenType::class, [ + 'data' => 'starting', + ]); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'suggested' => [], + ]); + } +} diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/views/Entity/thirdparty.html.twig b/src/Bundle/ChillThirdPartyBundle/Resources/views/Entity/thirdparty.html.twig index b3327e24d..8c500c6cc 100644 --- a/src/Bundle/ChillThirdPartyBundle/Resources/views/Entity/thirdparty.html.twig +++ b/src/Bundle/ChillThirdPartyBundle/Resources/views/Entity/thirdparty.html.twig @@ -171,7 +171,13 @@ - {% else %} + {% elseif is_granted('CHILL_3PARTY_3PARTY_UPDATE', thirdparty) %} +
  • + +
  • {% endif %} {% if options['customButtons']['after'] is defined %} diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdParty/view.html.twig b/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdParty/view.html.twig index bc5fc3325..2bd6f426f 100644 --- a/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdParty/view.html.twig +++ b/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdParty/view.html.twig @@ -128,7 +128,7 @@
    {% for tp in thirdParty.activeChildren %}
    - {{ tp|chill_entity_render_box({'render': 'bloc', 'addLink': false, 'isConfidential': tp.contactDataAnonymous ? true : false }) }} + {{ tp|chill_entity_render_box({'render': 'bloc', 'addLink': false, 'isConfidential': tp.contactDataAnonymous ? true : false, 'showFusion': true }) }}
    {% endfor %}
    diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdPartyDuplicate/_details.html.twig b/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdPartyDuplicate/_details.html.twig new file mode 100644 index 000000000..4d1f0cece --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdPartyDuplicate/_details.html.twig @@ -0,0 +1,34 @@ +{%- macro details(thirdparty, options) -%} + +
      +
    • {{ 'name'|trans }}: + {{ thirdparty.name }}
    • +
    • {{ 'First name'|trans }}: + {% if thirdparty.firstname %}{{ thirdparty.firstname }}{% endif %}
    • +
    • {{ 'thirdparty.Civility'|trans }}: + {% if thirdparty.getCivility %}{{ thirdparty.getCivility.name|localize_translatable_string }}{% endif %}
    • +
    • {{ 'thirdparty.NameCompany'|trans }}: + {% if thirdparty.nameCompany is not empty %}{{ thirdparty.nameCompany }}{% endif %}
    • +
    • {{ 'thirdparty.Acronym'|trans }}: + {% if thirdparty.acronym %}{{ thirdparty.acronym }}{% endif %}
    • +
    • {{ 'thirdparty.Profession'|trans }}: + {% if thirdparty.profession %}{{ thirdparty.profession }}{% endif %}
    • +
    • {{ 'telephone'|trans }}: + {% if thirdparty.telephone %}{{ thirdparty.telephone|chill_format_phonenumber }}{% endif %}
    • +
    • {{ 'email'|trans }}: + {% if thirdparty.email is not null %}{{ thirdparty.email }}{% endif %}
    • +
    • {{ 'Address'|trans }}: + {%- if thirdparty.getAddress is not empty -%} + {{ thirdparty.getAddress|chill_entity_render_box }} + {% endif %}
    • +
    • {{ 'thirdparty.Contact data are confidential'|trans }}: + {{ thirdparty.contactDataAnonymous }}
    • +
    • {{ 'Contacts'|trans }}: +
        + {% for c in thirdparty.getChildren %} +
      • {{ c.name }} {{ c.firstName }}
      • + {% endfor %} +
      +
    • +
    +{% endmacro %} diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdPartyDuplicate/confirm.html.twig b/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdPartyDuplicate/confirm.html.twig new file mode 100644 index 000000000..23d5507e7 --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdPartyDuplicate/confirm.html.twig @@ -0,0 +1,97 @@ +{% extends "@ChillMain/layout.html.twig" %} + +{% import '@ChillThirdParty/ThirdPartyDuplicate/_details.html.twig' as details %} + +{% block title %}{{ 'thirdparty_duplicate.Thirdparty duplicate title'|trans ~ ' ' ~ thirdparty.name }}{% endblock %} + +{% block content %} + +
    + +

    {{ 'thirdparty_duplicate.title'|trans }}

    + +
    +

    {{ 'thirdparty_duplicate.Thirdparty to delete'|trans }}: + {{ 'thirdparty_duplicate.Thirdparty to delete explanation'|trans }} +

    +
    + +

    + {{ thirdparty2 }} +

    + +

    {{ 'Deleted datas'|trans ~ ':' }}

    + {{ details.details(thirdparty2) }} + +{#

    {{ 'Moved links'|trans ~ ':' }}

    #} +{# {{ details.links(thirdparty2) }}#} +
    +
    + +
    +

    {{ 'thirdparty_duplicate.Thirdparty to keep'|trans }}: + {{ 'thirdparty_duplicate.Thirdparty to keep explanation'|trans }} +

    +
    + +

    + {{ thirdparty }} +

    + +

    {{ 'thirdparty_duplicate.Data to keep'|trans ~ ':' }}

    + {{ details.details(thirdparty) }} + +{#

    {{ 'thirdparty_duplicate.links to keep'|trans ~ ':' }}

    #} +{# {{ sidepane.links(thirdparty) }}#} +
    +
    + + {{ form_start(form) }} + +
    + +
    +
    + {{ form_widget(form.confirm) }} +
    +
    + {{ form_label(form.confirm) }} +
    +
    +
    + + + + {{ form_end(form) }} + +
    +{% endblock %} diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdPartyDuplicate/find_duplicate.html.twig b/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdPartyDuplicate/find_duplicate.html.twig new file mode 100644 index 000000000..f658fd728 --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdPartyDuplicate/find_duplicate.html.twig @@ -0,0 +1,38 @@ +{% extends "@ChillMain/layout.html.twig" %} + +{% set activeRouteKey = 'chill_thirdparty_duplicate' %} + +{% block title %}{{ 'thirdparty_duplicate.find'|trans ~ ' ' ~ thirdparty.name|capitalize }}{% endblock %} + + +{% block content %} +
    + +

    {{ 'thirdparty_duplicate.find'|trans }}

    + + {{ form_start(form) }} + {{ form_rest(form) }} + + + + {{ form_end(form) }} + +
    +{% endblock %} + +{% block js %} + {{ encore_entry_script_tags('mod_pickentity_type') }} +{% endblock %} + +{% block css %} + {{ encore_entry_link_tags('mod_pickentity_type') }} +{% endblock %} diff --git a/src/Bundle/ChillThirdPartyBundle/Service/ThirdpartyMergeService.php b/src/Bundle/ChillThirdPartyBundle/Service/ThirdpartyMergeService.php new file mode 100644 index 000000000..4eecb09b4 --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Service/ThirdpartyMergeService.php @@ -0,0 +1,115 @@ +em->getConnection(); + $conn->beginTransaction(); + + try { + $queries = [ + ...$this->updateReferences($toKeep, $toDelete), + ...$this->removeThirdparty($toKeep, $toDelete), + ]; + + foreach ($queries as $query) { + $conn->executeStatement($query['sql'], $query['params']); + } + + $conn->commit(); + } catch (\Exception $e) { + $conn->rollBack(); + throw $e; + } + } + + /** + * @throws MappingException + */ + private function updateReferences(ThirdParty $toKeep, ThirdParty $toDelete): array + { + $queries = []; + $allMeta = $this->em->getMetadataFactory()->getAllMetadata(); + + foreach ($allMeta as $meta) { + if ($meta->isMappedSuperclass) { + continue; + } + + $tableName = $meta->getTableName(); + foreach ($meta->getAssociationMappings() as $assoc) { + if (ThirdParty::class !== $assoc['targetEntity']) { + continue; + } + + // phpstan wants boolean for if condition + if (($assoc['type'] & ClassMetadata::TO_ONE) !== 0) { + $joinColumn = $meta->getSingleAssociationJoinColumnName($assoc['fieldName']); + + $suffix = (ThirdParty::class === $assoc['sourceEntity']) ? 'chill_3party.' : ''; + + $queries[] = [ + 'sql' => "UPDATE {$suffix}{$tableName} SET {$joinColumn} = :toKeep WHERE {$joinColumn} = :toDelete", + 'params' => ['toKeep' => $toKeep->getId(), 'toDelete' => $toDelete->getId()], + ]; + } elseif (ClassMetadata::MANY_TO_MANY === $assoc['type'] && isset($assoc['joinTable'])) { + $joinTable = $assoc['joinTable']['name']; + $prefix = null !== ($assoc['joinTable']['schema'] ?? null) ? $assoc['joinTable']['schema'].'.' : ''; + $joinColumn = $assoc['joinTable']['inverseJoinColumns'][0]['name']; + $queries[] = [ + 'sql' => "UPDATE {$prefix}{$joinTable} SET {$joinColumn} = :toKeep WHERE {$joinColumn} = :toDelete AND NOT EXISTS (SELECT 1 FROM {$prefix}{$joinTable} WHERE {$joinColumn} = :toKeep)", + 'params' => ['toDelete' => $toDelete->getId(), 'toKeep' => $toKeep->getId()], + ]; + + $queries[] = [ + 'sql' => "DELETE FROM {$joinTable} WHERE {$joinColumn} = :toDelete", + 'params' => ['toDelete' => $toDelete->getId()], + ]; + } + } + } + + return $queries; + } + + public function removeThirdparty(ThirdParty $toKeep, ThirdParty $toDelete): array + { + return [ + [ + 'sql' => 'UPDATE chill_3party.third_party SET parent_id = :toKeep WHERE parent_id = :toDelete', + 'params' => ['toKeep' => $toKeep->getId(), 'toDelete' => $toDelete->getId()], + ], + [ + 'sql' => 'UPDATE chill_3party.thirdparty_category SET thirdparty_id = :toKeep WHERE thirdparty_id = :toDelete AND NOT EXISTS (SELECT 1 FROM chill_3party.thirdparty_category WHERE thirdparty_id = :toKeep)', + 'params' => ['toKeep' => $toKeep->getId(), 'toDelete' => $toDelete->getId()], + ], + [ + 'sql' => 'DELETE FROM chill_3party.third_party WHERE id = :toDelete', + 'params' => ['toDelete' => $toDelete->getId()], + ], + ]; + } +} diff --git a/src/Bundle/ChillThirdPartyBundle/Templating/Entity/ThirdPartyRender.php b/src/Bundle/ChillThirdPartyBundle/Templating/Entity/ThirdPartyRender.php index a36ceda4e..427f2b9e3 100644 --- a/src/Bundle/ChillThirdPartyBundle/Templating/Entity/ThirdPartyRender.php +++ b/src/Bundle/ChillThirdPartyBundle/Templating/Entity/ThirdPartyRender.php @@ -39,6 +39,7 @@ class ThirdPartyRender implements ChillEntityRenderInterface 'showContacts' => $options['showContacts'] ?? false, 'showParent' => $options['showParent'] ?? true, 'isConfidential' => $options['isConfidential'] ?? false, + 'showFusion' => $options['showFusion'] ?? false, ]; return diff --git a/src/Bundle/ChillThirdPartyBundle/Tests/Service/ThirdpartyMergeServiceTest.php b/src/Bundle/ChillThirdPartyBundle/Tests/Service/ThirdpartyMergeServiceTest.php new file mode 100644 index 000000000..4b2819751 --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Tests/Service/ThirdpartyMergeServiceTest.php @@ -0,0 +1,86 @@ +em = self::getContainer()->get(EntityManagerInterface::class); + + $this->service = new ThirdpartyMergeService($this->em); + } + + public function testMergeUpdatesReferencesAndDeletesThirdparty(): void + { + // Create ThirdParty entities + $toKeep = new ThirdParty(); + $toKeep->setName('Thirdparty to keep'); + $this->em->persist($toKeep); + + $toDelete = new ThirdParty(); + $toDelete->setName('Thirdparty to delete'); + $this->em->persist($toDelete); + + // Create a related entity with TO_ONE relation (thirdparty parent) + $relatedToOneEntity = new ThirdParty(); + $relatedToOneEntity->setName('RelatedToOne thirdparty'); + $relatedToOneEntity->setParent($toDelete); + $this->em->persist($relatedToOneEntity); + + // Create a related entity with TO_MANY relation (thirdparty category) + $thirdpartyCategory = new ThirdPartyCategory(); + $thirdpartyCategory->setName(['fr' => 'Thirdparty category']); + $this->em->persist($thirdpartyCategory); + $toDelete->addCategory($thirdpartyCategory); + $this->em->persist($toDelete); + + $activity = new Activity(); + $activity->setDate(new \DateTime()); + $activity->addThirdParty($toDelete); + $this->em->persist($activity); + + $this->em->flush(); + + // Run merge + $this->service->merge($toKeep, $toDelete); + $this->em->refresh($toKeep); + $this->em->refresh($relatedToOneEntity); + + // Check that references were updated + $this->assertEquals($toKeep->getId(), $relatedToOneEntity->getParent()->getId(), 'The parent thirdparty was succesfully merged'); + + $updatedRelatedManyEntity = $this->em->find(ThirdPartyCategory::class, $thirdpartyCategory->getId()); + $this->assertContains($updatedRelatedManyEntity, $toKeep->getCategories(), 'The thirdparty category was found in the toKeep entity'); + + // Check that toDelete was removed + $this->em->clear(); + $deletedThirdParty = $this->em->find(ThirdParty::class, $toDelete->getId()); + $this->assertNull($deletedThirdParty); + } +} diff --git a/src/Bundle/ChillThirdPartyBundle/config/services.yaml b/src/Bundle/ChillThirdPartyBundle/config/services.yaml index 572026771..ffe3207d3 100644 --- a/src/Bundle/ChillThirdPartyBundle/config/services.yaml +++ b/src/Bundle/ChillThirdPartyBundle/config/services.yaml @@ -1,14 +1,14 @@ ---- services: - Chill\ThirdPartyBundle\Serializer\Normalizer\: + _defaults: autowire: true + autoconfigure: true + + Chill\ThirdPartyBundle\Serializer\Normalizer\: resource: '../Serializer/Normalizer/' tags: - { name: 'serializer.normalizer', priority: 64 } Chill\ThirdPartyBundle\Export\: - autowire: true - autoconfigure: true resource: '../Export/' Chill\ThirdPartyBundle\Validator\: @@ -16,3 +16,5 @@ services: autowire: true resource: '../Validator/' + Chill\ThirdPartyBundle\Service\ThirdpartyMergeService: ~ + diff --git a/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml b/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml index de2c489bb..62814e1e9 100644 --- a/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml @@ -135,7 +135,6 @@ is thirdparty: Le demandeur est un tiers Filter by person's who have a residential address located at a thirdparty of type: Filtrer les usagers qui ont une addresse de résidence chez un tiers "Filtered by person's who have a residential address located at a thirdparty of type %thirdparty_type% and valid on %date_calc%": "Uniquement les usagers qui ont une addresse de résidence chez un tiers de catégorie %thirdparty_type% et valide sur la date %date_calc%" - # admin admin: export_description: Liste des tiers (format CSV) @@ -168,3 +167,16 @@ thirdpartyMessages: civility: "Civilité" child_of: "Contact de: " children: "Personnes de contact: " + +thirdparty_duplicate: + title: Fusionner les tiers doublons + find: Désigner un tiers doublon + Thirdparty to keep: Tiers à conserver + Thirdparty to delete: Tiers à supprimer + Thirdparty to delete explanation: Ce tiers sera supprimé. Seuls les contacts de ce tiers, énumérés ci-dessous, seront transférés. + Thirdparty to keep explanation: Ce tiers sera conservé + Data to keep: Données conservées + You cannot merge a thirdparty with itself. Please choose a different thirdparty: Vous ne pouvez pas fusionner un tiers avec lui-même. Veuillez choisir un autre tiers. + A thirdparty can only be merged with a thirdparty of the same kind: Un tiers ne peut être fusionné qu'avec un tiers de même type. + Two child thirdparties must have the same parent: Deux tiers de type « contact » doivent avoir le même tiers parent. + Merge successful: La fusion a été effectuée avec succès