diff --git a/CHANGELOG.md b/CHANGELOG.md index caf897a68..0f60738b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ and this project adheres to ## Unreleased +* [person] AccompanyingPeriodWorkEvaluation: fix circular reference when serialising (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/495) +* [person] order accompanying period by opening date in search persons, person and household period lists (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/493) +* [parcours] autosave of the pinned comment for draft accompanying course (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/477) +* [main] filter user job in undispatch acc period to assign (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/472) +* [main] filter user job in undispatch acc period to assign (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/472) +* [person] Add url in accompanying period work evaluations entity and form (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/476) * [person] Add document generation in admin and in person/{id}/document (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/464) * [activity] do not override location if already exist (when validating new activity) (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/470) * [parcours] Toggle emergency/intensity only by referrer (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/442) @@ -28,8 +34,20 @@ and this project adheres to * [confidential] Fix position of toggle button so it does not cover text nor fall outside of box (no issue) * [parcours] Fix edit of both thirdparty and contact name (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/474) * [template] do not list inactive templates (for doc generator) +* [household] bugfix if position of member is null, renderbox no longer throws an error (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/480) +* [parcours] location cannot be removed if linked to a user (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/478) * [person] email added to twig personRenderbox (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/490) * [activity] Only youngest descendant is kept for social issues and actions (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/471) +* [person] Add link to current household in person banner (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/484) +* [address] person badge in address history changed to open OnTheFly with all person info (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/489) +* [person] Change 'personne' with 'usager' and '&' with 'ET' (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/499) +* [thirdparty] Add parameter condition to display centers or not (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/500) +* [phonenumber] Remove placeholder in phonenumber field (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/496) +* [person_resource] separate create page created to avoid confusion (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/504) +* [contact] add contact button color changed plus the pipe at the side removed (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/506) +* [household] create-edit household composition placed in separate page to avoid confusion (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/505) +* [blur] Improved positioning of toggle icon (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/486) + ## Test releases ### test release 2022-02-21 diff --git a/phpstan-types.neon b/phpstan-types.neon index ed2de3c91..ddde5cc03 100644 --- a/phpstan-types.neon +++ b/phpstan-types.neon @@ -350,11 +350,6 @@ parameters: count: 6 path: src/Bundle/ChillPersonBundle/Command/ImportPeopleFromCSVCommand.php - - - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" - count: 2 - path: src/Bundle/ChillPersonBundle/Entity/PersonPhone.php - - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" count: 1 diff --git a/src/Bundle/ChillDocStoreBundle/Workflow/AccompanyingCourseDocumentWorkflowHandler.php b/src/Bundle/ChillDocStoreBundle/Workflow/AccompanyingCourseDocumentWorkflowHandler.php index e3cc9c408..3d7b95c61 100644 --- a/src/Bundle/ChillDocStoreBundle/Workflow/AccompanyingCourseDocumentWorkflowHandler.php +++ b/src/Bundle/ChillDocStoreBundle/Workflow/AccompanyingCourseDocumentWorkflowHandler.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Workflow; use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument; +use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface; use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation; @@ -36,6 +37,13 @@ class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkflowHandler $this->translator = $translator; } + public function getDeletionRoles(): array + { + return [ + AccompanyingCourseDocumentVoter::DELETE, + ]; + } + public function getEntityData(EntityWorkflow $entityWorkflow, array $options = []): array { $course = $this->getRelatedEntity($entityWorkflow) @@ -66,6 +74,18 @@ class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkflowHandler return $this->repository->find($entityWorkflow->getRelatedEntityId()); } + /** + * @param AccompanyingCourseDocument $object + * + * @return array[] + */ + public function getRelatedObjects(object $object): array + { + return [ + ['entityClass' => AccompanyingCourseDocument::class, 'entityId' => $object->getId()], + ]; + } + public function getRoleShow(EntityWorkflow $entityWorkflow): ?string { return null; @@ -84,6 +104,11 @@ class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkflowHandler ]; } + public function isObjectSupported(object $object): bool + { + return $object instanceof AccompanyingCourseDocument; + } + public function supports(EntityWorkflow $entityWorkflow, array $options = []): bool { return $entityWorkflow->getRelatedEntityClass() === AccompanyingCourseDocument::class; diff --git a/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php b/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php index 5ad7348ee..09668f0e3 100644 --- a/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php +++ b/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php @@ -256,6 +256,13 @@ class ChillMainExtension extends Extension implements 'channels' => ['chill'], ]); + $container->prependExtensionConfig('security', [ + 'access_decision_manager' => [ + 'strategy' => 'unanimous', + 'allow_if_all_abstain' => false, + ], + ]); + //add crud api $this->prependCruds($container); } diff --git a/src/Bundle/ChillMainBundle/Entity/UserJob.php b/src/Bundle/ChillMainBundle/Entity/UserJob.php index 5f0bea45d..99db5d4f1 100644 --- a/src/Bundle/ChillMainBundle/Entity/UserJob.php +++ b/src/Bundle/ChillMainBundle/Entity/UserJob.php @@ -40,6 +40,7 @@ class UserJob * @var array|string[]A * @ORM\Column(name="label", type="json") * @Serializer\Groups({"read", "docgen:read"}) + * @Serializer\Context({"is-translatable": true}, groups={"docgen:read"}) */ protected array $label = []; diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php index 9de76e039..b978173d9 100644 --- a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php @@ -192,7 +192,7 @@ class EntityWorkflowStep * You should **not** rely on this method to get all users which are able to * apply a transition on this step. Use @see{EntityWorkflowStep::getAllDestUser} instead. */ - public function getDestUser(): collection + public function getDestUser(): Collection { return $this->destUser; } diff --git a/src/Bundle/ChillMainBundle/Form/Type/ChillPhoneNumberType.php b/src/Bundle/ChillMainBundle/Form/Type/ChillPhoneNumberType.php index 547782943..2580058e8 100644 --- a/src/Bundle/ChillMainBundle/Form/Type/ChillPhoneNumberType.php +++ b/src/Bundle/ChillMainBundle/Form/Type/ChillPhoneNumberType.php @@ -16,9 +16,7 @@ use libphonenumber\PhoneNumberUtil; use Misd\PhoneNumberBundle\Form\Type\PhoneNumberType; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\Form\AbstractType; -use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; -use function array_key_exists; class ChillPhoneNumberType extends AbstractType { @@ -37,21 +35,7 @@ class ChillPhoneNumberType extends AbstractType $resolver ->setDefault('default_region', $this->defaultCarrierCode) ->setDefault('format', PhoneNumberFormat::NATIONAL) - ->setDefault('type', \libphonenumber\PhoneNumberType::FIXED_LINE_OR_MOBILE) - ->setNormalizer('attr', function (Options $options, $value) { - if (array_key_exists('placeholder', $value)) { - return $value; - } - - $examplePhoneNumber = $this->phoneNumberUtil->getExampleNumberForType($this->defaultCarrierCode, $options['type']); - - return array_merge( - $value, - [ - 'placeholder' => PhoneNumberUtil::getInstance()->format($examplePhoneNumber, $options['format']), - ] - ); - }); + ->setDefault('type', \libphonenumber\PhoneNumberType::FIXED_LINE_OR_MOBILE); } public function getParent() diff --git a/src/Bundle/ChillMainBundle/Phonenumber/PhoneNumberHelperInterface.php b/src/Bundle/ChillMainBundle/Phonenumber/PhoneNumberHelperInterface.php index eeab9c38d..1ed67d967 100644 --- a/src/Bundle/ChillMainBundle/Phonenumber/PhoneNumberHelperInterface.php +++ b/src/Bundle/ChillMainBundle/Phonenumber/PhoneNumberHelperInterface.php @@ -27,7 +27,7 @@ interface PhoneNumberHelperInterface /** * Get type (mobile, landline, ...) for phone number. */ - public function getType(string $phonenumber): string; + public function getType(PhoneNumber $phonenumber): string; /** * Return true if the validation is configured and available. diff --git a/src/Bundle/ChillMainBundle/Phonenumber/PhonenumberHelper.php b/src/Bundle/ChillMainBundle/Phonenumber/PhonenumberHelper.php index c73b3dc95..22f580d78 100644 --- a/src/Bundle/ChillMainBundle/Phonenumber/PhonenumberHelper.php +++ b/src/Bundle/ChillMainBundle/Phonenumber/PhonenumberHelper.php @@ -17,6 +17,7 @@ use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Exception\ServerException; use libphonenumber\NumberParseException; use libphonenumber\PhoneNumber; +use libphonenumber\PhoneNumberType; use libphonenumber\PhoneNumberUtil; use Psr\Cache\CacheItemPoolInterface; use Psr\Log\LoggerInterface; @@ -86,9 +87,19 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface /** * Get type (mobile, landline, ...) for phone number. */ - public function getType(string $phonenumber): string + public function getType(PhoneNumber $phonenumber): string { - return $this->performTwilioLookup($phonenumber) ?? 'unknown'; + switch ($this->phoneNumberUtil->getNumberType($phonenumber)) { + case PhoneNumberType::MOBILE: + return 'mobile'; + + case PhoneNumberType::FIXED_LINE: + case PhoneNumberType::VOIP: + return 'landline'; + + default: + return 'landline'; + } } /** diff --git a/src/Bundle/ChillMainBundle/Repository/Workflow/EntityWorkflowRepository.php b/src/Bundle/ChillMainBundle/Repository/Workflow/EntityWorkflowRepository.php index ab58801fc..a73da74b0 100644 --- a/src/Bundle/ChillMainBundle/Repository/Workflow/EntityWorkflowRepository.php +++ b/src/Bundle/ChillMainBundle/Repository/Workflow/EntityWorkflowRepository.php @@ -55,6 +55,30 @@ class EntityWorkflowRepository implements ObjectRepository return (int) $qb->getQuery()->getSingleScalarResult(); } + public function countRelatedWorkflows(array $relateds): int + { + $qb = $this->repository->createQueryBuilder('w'); + + $orX = $qb->expr()->orX(); + $i = 0; + + foreach ($relateds as $related) { + $orX->add( + $qb->expr()->andX( + $qb->expr()->eq('w.relatedEntityClass', ':entity_class_' . $i), + $qb->expr()->eq('w.relatedEntityId', ':entity_id_' . $i) + ) + ); + $qb + ->setParameter('entity_class_' . $i, $related['entityClass']) + ->setParameter('entity_id_' . $i, $related['entityId']); + ++$i; + } + $qb->where($orX); + + return $qb->select('COUNT(w)')->getQuery()->getSingleScalarResult(); + } + public function find($id): ?EntityWorkflow { return $this->repository->find($id); diff --git a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/buttons.scss b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/buttons.scss index 3a9b6df80..248b32cfc 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/buttons.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/buttons.scss @@ -25,7 +25,7 @@ $chill-theme-buttons: ( "notify": $chill-blue, "search": $gray-300, "unlink": $chill-red, - "tpchild": $chill-pink, + "tpchild": $chill-green, ); @each $button, $color in $chill-theme-buttons { diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.js b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.js index 67f3e2f42..75f1a21c8 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.js +++ b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.js @@ -1,17 +1,22 @@ /** * Generic api method that can be adapted to any fetch request */ -const makeFetch = (method, url, body) => { - return fetch(url, { +const makeFetch = (method, url, body, options) => { + let opts = { method: method, headers: { 'Content-Type': 'application/json;charset=utf-8' }, body: (body !== null) ? JSON.stringify(body) : null - }) + }; + + if (typeof options !== 'undefined') { + opts = Object.assign(opts, options); + } + + return fetch(url, opts) .then(response => { if (response.ok) { - console.log('200 error') return response.json(); } diff --git a/src/Bundle/ChillMainBundle/Resources/public/module/blur/blur.scss b/src/Bundle/ChillMainBundle/Resources/public/module/blur/blur.scss index a53ea69d9..9f2ef7bbe 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/module/blur/blur.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/module/blur/blur.scss @@ -1,37 +1,26 @@ .confidential { display: flex; position: relative; -} -.toggle-far-twig { - i { - bottom: 0px; - right: -30px; - } + margin-right: 20px } -.toggle-close-twig { - i { - bottom: 0px; - right: -5px; - } +.toggle-container { + position: absolute; + width: 100%; + top: 0; + left: 0; + cursor: pointer; + z-index: 5; + padding-right: 1rem; } .toggle{ - margin-left: 30px; - margin-top: 5px; - cursor: pointer; position: absolute; - z-index: 5; - right: -30px -} - -.toggle-far { - bottom: 0px; - right: 20px !important; -} - -.toggle-close { - bottom: 125px; - right: 15px !important; + right: 4px; + &-twig { + position: absolute; + right: -25px; + bottom: 20px; + } } .blur { diff --git a/src/Bundle/ChillMainBundle/Resources/public/module/blur/index.js b/src/Bundle/ChillMainBundle/Resources/public/module/blur/index.js index 1d66d25e6..3245512e9 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/module/blur/index.js +++ b/src/Bundle/ChillMainBundle/Resources/public/module/blur/index.js @@ -2,18 +2,19 @@ require('./blur.scss'); document.querySelectorAll('.confidential').forEach(function (el) { let i = document.createElement('i'); - const classes = ['fa', 'fa-eye', 'toggle']; + const classes = ['fa', 'fa-eye-slash', 'toggle-twig']; i.classList.add(...classes); el.appendChild(i); + const toggleBlur = function(e) { for (let child of el.children) { - if (!child.classList.contains('toggle')) { + if (!child.classList.contains('toggle-twig')) { child.classList.toggle('blur'); } } - i.classList.toggle('fa-eye'); i.classList.toggle('fa-eye-slash'); + i.classList.toggle('fa-eye'); } i.addEventListener('click', toggleBlur); toggleBlur(); -}); +}); \ No newline at end of file diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/api.js b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/api.js index 1dbc85dee..b1489bfb6 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/api.js +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/api.js @@ -22,6 +22,7 @@ const fetchCountries = () => { */ const fetchCities = (country) => { //console.log('<<< fetching cities for', country); + // warning: do not use fetchResults (in apiMethods): we need only a **part** of the results in the db const url = `/api/1.0/main/postal-code.json?item_per_page=1000&country=${country.id}`; return fetch(url) .then(response => { diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/HomepageWidget/MyEvaluations.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/HomepageWidget/MyEvaluations.vue index 7cd40f45a..a872a2f43 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/HomepageWidget/MyEvaluations.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/HomepageWidget/MyEvaluations.vue @@ -42,9 +42,6 @@ {{ $t('show_entity', { entity: $t('the_evaluation') }) }} - - {{ $t('show_entity', { entity: $t('the_action') }) }} - {{ $t('show_entity', { entity: $t('the_course') }) }} @@ -102,4 +99,4 @@ export default { \ No newline at end of file + diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Confidential.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Confidential.vue index f5a555520..837440524 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Confidential.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Confidential.vue @@ -3,8 +3,8 @@
-
- +
+
@@ -12,28 +12,24 @@ diff --git a/src/Bundle/ChillMainBundle/Resources/views/Entity/address.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Entity/address.html.twig index 54a0b86b7..3dfc27d2f 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Entity/address.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Entity/address.html.twig @@ -59,7 +59,7 @@ must be shown in such list #} {%- if render == 'list' -%} -
  • +
  • {% if options['with_picto'] %} {% endif %} @@ -68,7 +68,7 @@ {%- endif -%} {%- if render == 'inline' -%} - + {% if options['with_picto'] %} {% endif %} @@ -77,7 +77,7 @@ {%- endif -%} {%- if render == 'bloc' -%} -
    +
    {% if options['has_no_address'] == true and address.isNoAddress == true %} {% if address.postCode is not empty %}
    diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/macro_breadcrumb.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/macro_breadcrumb.html.twig index 6fb3fe2ff..a62f37e0a 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/macro_breadcrumb.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/macro_breadcrumb.html.twig @@ -9,6 +9,12 @@ {{ 'Le'|trans ~ ' : ' }} {{ step.previous.transitionAt|format_datetime('short', 'short') }}
  • +
  • + {{ 'workflow.For'|trans ~ ' : ' }} + + {% for d in step.destUser %}{{ d|chill_entity_render_string }}{% if not loop.last %}, {% endif %}{% endfor %} + +
  • {% else %}
  • {{ 'workflow.Created by'|trans ~ ' : ' }} diff --git a/src/Bundle/ChillMainBundle/Security/Authorization/WorkflowEntityDeletionVoter.php b/src/Bundle/ChillMainBundle/Security/Authorization/WorkflowEntityDeletionVoter.php new file mode 100644 index 000000000..079c43c3b --- /dev/null +++ b/src/Bundle/ChillMainBundle/Security/Authorization/WorkflowEntityDeletionVoter.php @@ -0,0 +1,65 @@ +handlers = $handlers; + $this->entityWorkflowRepository = $entityWorkflowRepository; + } + + protected function supports($attribute, $subject) + { + if (!is_object($subject)) { + return false; + } + + foreach ($this->handlers as $handler) { + if ($handler->isObjectSupported($subject) + && in_array($attribute, $handler->getDeletionRoles($subject), true)) { + return true; + } + } + + return false; + } + + protected function voteOnAttribute($attribute, $subject, TokenInterface $token) + { + foreach ($this->handlers as $handler) { + if ($handler->isObjectSupported($subject)) { + return 0 === $this->entityWorkflowRepository->countRelatedWorkflows( + $handler->getRelatedObjects($subject) + ); + } + } + + throw new RuntimeException('no handlers found'); + } +} diff --git a/src/Bundle/ChillMainBundle/Serializer/Normalizer/AddressNormalizer.php b/src/Bundle/ChillMainBundle/Serializer/Normalizer/AddressNormalizer.php index 78e8729be..ccb48c160 100644 --- a/src/Bundle/ChillMainBundle/Serializer/Normalizer/AddressNormalizer.php +++ b/src/Bundle/ChillMainBundle/Serializer/Normalizer/AddressNormalizer.php @@ -113,6 +113,7 @@ class AddressNormalizer implements ContextAwareNormalizerInterface, NormalizerAw [ 'postcode' => $helper->normalize(self::NULL_POSTCODE_COUNTRY, $format, $context), 'country' => $helper->normalize(self::NULL_POSTCODE_COUNTRY, $format, $context), + 'lines' => [], ] ); } diff --git a/src/Bundle/ChillMainBundle/Serializer/Normalizer/PhonenumberNormalizer.php b/src/Bundle/ChillMainBundle/Serializer/Normalizer/PhonenumberNormalizer.php index cb59e6421..677199ad4 100644 --- a/src/Bundle/ChillMainBundle/Serializer/Normalizer/PhonenumberNormalizer.php +++ b/src/Bundle/ChillMainBundle/Serializer/Normalizer/PhonenumberNormalizer.php @@ -16,10 +16,11 @@ use libphonenumber\PhoneNumber; use libphonenumber\PhoneNumberUtil; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -class PhonenumberNormalizer implements NormalizerInterface, DenormalizerInterface +class PhonenumberNormalizer implements ContextAwareNormalizerInterface, DenormalizerInterface { private string $defaultCarrierCode; @@ -40,6 +41,10 @@ class PhonenumberNormalizer implements NormalizerInterface, DenormalizerInterfac */ public function denormalize($data, $type, $format = null, array $context = []) { + if ('' === trim($data)) { + return null; + } + try { return $this->phoneNumberUtil->parse($data, $this->defaultCarrierCode); } catch (NumberParseException $e) { @@ -49,6 +54,10 @@ class PhonenumberNormalizer implements NormalizerInterface, DenormalizerInterfac public function normalize($object, ?string $format = null, array $context = []): string { + if ($format === 'docgen' && null === $object) { + return ''; + } + return $this->phoneNumberUtil->formatOutOfCountryCallingNumber($object, $this->defaultCarrierCode); } @@ -57,8 +66,18 @@ class PhonenumberNormalizer implements NormalizerInterface, DenormalizerInterfac return 'libphonenumber\PhoneNumber' === $type; } - public function supportsNormalization($data, ?string $format = null) + public function supportsNormalization($data, ?string $format = null, array $context = []): bool { - return $data instanceof PhoneNumber; + if ($data instanceof PhoneNumber && $format === 'json') { + return true; + } + + if ($format === 'docgen' && ( + $data instanceof PhoneNumber || PhoneNumber::class === ($context['docgen:expects'] ?? null) + )) { + return true; + } + + return false; } } diff --git a/src/Bundle/ChillMainBundle/Workflow/EntityWorkflowHandlerInterface.php b/src/Bundle/ChillMainBundle/Workflow/EntityWorkflowHandlerInterface.php index 93ba4351e..b63546742 100644 --- a/src/Bundle/ChillMainBundle/Workflow/EntityWorkflowHandlerInterface.php +++ b/src/Bundle/ChillMainBundle/Workflow/EntityWorkflowHandlerInterface.php @@ -15,12 +15,19 @@ use Chill\MainBundle\Entity\Workflow\EntityWorkflow; interface EntityWorkflowHandlerInterface { + /** + * @return array|string[] + */ + public function getDeletionRoles(): array; + public function getEntityData(EntityWorkflow $entityWorkflow, array $options = []): array; public function getEntityTitle(EntityWorkflow $entityWorkflow, array $options = []): string; public function getRelatedEntity(EntityWorkflow $entityWorkflow): ?object; + public function getRelatedObjects(object $object): array; + /** * Return a string representing the role required for seeing the workflow. * @@ -33,6 +40,8 @@ interface EntityWorkflowHandlerInterface public function getTemplateData(EntityWorkflow $entityWorkflow, array $options = []): array; + public function isObjectSupported(object $object): bool; + public function supports(EntityWorkflow $entityWorkflow, array $options = []): bool; public function supportsFreeze(EntityWorkflow $entityWorkflow, array $options = []): bool; diff --git a/src/Bundle/ChillMainBundle/config/services/security.yaml b/src/Bundle/ChillMainBundle/config/services/security.yaml index b884a92fc..3347871e3 100644 --- a/src/Bundle/ChillMainBundle/config/services/security.yaml +++ b/src/Bundle/ChillMainBundle/config/services/security.yaml @@ -75,3 +75,9 @@ services: $locker: '@Chill\MainBundle\Security\PasswordRecover\PasswordRecoverLocker' tags: - { name: security.voter } + + Chill\MainBundle\Security\Authorization\WorkflowEntityDeletionVoter: + autoconfigure: true + autowire: true + arguments: + $handlers: !tagged_iterator chill_main.workflow_handler diff --git a/src/Bundle/ChillMainBundle/translations/messages.fr.yml b/src/Bundle/ChillMainBundle/translations/messages.fr.yml index e907f187f..685a0c373 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.fr.yml @@ -413,6 +413,7 @@ workflow: Previous workflow without reaction help: Liste des workflows où vous avez été cité comme pouvant réagir à une étape, mais où un autre utilisateur a exécuté une action avant vous. Previous transitionned: Anciens workflows Previous workflow transitionned help: Workflows où vous avez exécuté une action. + For: Pour Subscribe final: Recevoir une notification à l'étape finale diff --git a/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodController.php b/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodController.php index bb0081c27..c34702ad1 100644 --- a/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodController.php +++ b/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodController.php @@ -222,6 +222,10 @@ class AccompanyingPeriodController extends AbstractController $accompanyingPeriodsRaw = $this->accompanyingPeriodACLAwareRepository ->findByPerson($person, AccompanyingPeriodVoter::SEE); + usort($accompanyingPeriodsRaw, static function ($a, $b) { + return $b->getOpeningDate() > $a->getOpeningDate(); + }); + // filter visible or not visible $accompanyingPeriods = array_filter($accompanyingPeriodsRaw, function (AccompanyingPeriod $ap) { return $this->isGranted(AccompanyingPeriodVoter::SEE, $ap); diff --git a/src/Bundle/ChillPersonBundle/Controller/HouseholdCompositionController.php b/src/Bundle/ChillPersonBundle/Controller/HouseholdCompositionController.php index 22e92eb02..1144688fe 100644 --- a/src/Bundle/ChillPersonBundle/Controller/HouseholdCompositionController.php +++ b/src/Bundle/ChillPersonBundle/Controller/HouseholdCompositionController.php @@ -134,7 +134,7 @@ class HouseholdCompositionController extends AbstractController public function index(Household $household, Request $request): Response { if (!$this->security->isGranted(HouseholdVoter::SEE, $household)) { - throw new AccessDeniedException('not allowed to edit an household'); + throw new AccessDeniedException('not allowed to edit a household'); } $count = $this->householdCompositionRepository->countByHousehold($household); @@ -146,6 +146,20 @@ class HouseholdCompositionController extends AbstractController $paginator->getCurrentPageFirstItemNumber() ); + return new Response($this->engine->render( + '@ChillPerson/HouseholdComposition/index.html.twig', + [ + 'household' => $household, + 'compositions' => $compositions, + ] + )); + } + + /** + * @Route("/{_locale}/person/household/{id}/composition/new", name="chill_person_household_composition_new") + */ + public function newAction(Household $household, Request $request): Response + { if ($this->security->isGranted(HouseholdVoter::EDIT, $household)) { $isEdit = $request->query->has('edit'); @@ -195,10 +209,9 @@ class HouseholdCompositionController extends AbstractController } return new Response($this->engine->render( - '@ChillPerson/HouseholdComposition/index.html.twig', + '@ChillPerson/HouseholdComposition/create.html.twig', [ 'household' => $household, - 'compositions' => $compositions, 'form' => isset($form) ? $form->createView() : null, 'isPosted' => isset($form) ? $form->isSubmitted() : false, 'editId' => $request->query->getInt('edit', -1), diff --git a/src/Bundle/ChillPersonBundle/Controller/HouseholdController.php b/src/Bundle/ChillPersonBundle/Controller/HouseholdController.php index 07fa34b5a..0a8b7eea1 100644 --- a/src/Bundle/ChillPersonBundle/Controller/HouseholdController.php +++ b/src/Bundle/ChillPersonBundle/Controller/HouseholdController.php @@ -78,6 +78,10 @@ class HouseholdController extends AbstractController } } + usort($accompanyingPeriods, static function ($a, $b) { + return $b->getOpeningDate() > $a->getOpeningDate(); + }); + $oldMembers = $household->getNonCurrentMembers(); $accompanyingPeriodsOld = []; diff --git a/src/Bundle/ChillPersonBundle/Controller/PersonResourceController.php b/src/Bundle/ChillPersonBundle/Controller/PersonResourceController.php index 210b507e5..004961594 100644 --- a/src/Bundle/ChillPersonBundle/Controller/PersonResourceController.php +++ b/src/Bundle/ChillPersonBundle/Controller/PersonResourceController.php @@ -133,6 +133,19 @@ final class PersonResourceController extends AbstractController $personResources = []; $personResources = $this->personResourceRepository->findBy(['personOwner' => $personOwner->getId()]); + return $this->render( + 'ChillPersonBundle:PersonResource:list.html.twig', + [ + 'person' => $personOwner, + 'personResources' => $personResources, + ] + ); + } + + public function newAction(Request $request, $person_id) + { + $personOwner = $this->personRepository->find($person_id); + $form = $this->createForm(PersonResourceType::class); $form->handleRequest($request); @@ -165,11 +178,10 @@ final class PersonResourceController extends AbstractController } return $this->render( - 'ChillPersonBundle:PersonResource:list.html.twig', + 'ChillPersonBundle:PersonResource:create.html.twig', [ - 'person' => $personOwner, - 'personResources' => $personResources, 'form' => $form->createView(), + 'person' => $personOwner, ] ); } diff --git a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php index b05da5626..1f85e4ad0 100644 --- a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php +++ b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php @@ -15,6 +15,7 @@ use Chill\MainBundle\DependencyInjection\MissingBundleException; use Chill\MainBundle\Security\Authorization\ChillExportVoter; use Chill\PersonBundle\Controller\HouseholdCompositionTypeApiController; use Chill\PersonBundle\Doctrine\DQL\AddressPart; +use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodCommentVoter; use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodResourceVoter; use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter; use Chill\PersonBundle\Security\Authorization\PersonVoter; @@ -415,6 +416,25 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac ], ], ], + [ + 'class' => \Chill\PersonBundle\Entity\AccompanyingPeriod\Comment::class, + 'name' => 'accompanying_period_comment', + 'base_path' => '/api/1.0/person/accompanying-period/comment', + 'base_role' => 'ROLE_USER', + 'actions' => [ + '_entity' => [ + 'methods' => [ + Request::METHOD_GET => false, + Request::METHOD_PATCH => true, + Request::METHOD_HEAD => false, + Request::METHOD_DELETE => false, + ], + 'roles' => [ + Request::METHOD_PATCH => AccompanyingPeriodCommentVoter::EDIT, + ], + ], + ], + ], [ 'class' => \Chill\PersonBundle\Entity\AccompanyingPeriod\Resource::class, 'name' => 'accompanying_period_resource', diff --git a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php index d75ade12b..bd3211c5d 100644 --- a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php +++ b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php @@ -257,9 +257,11 @@ class AccompanyingPeriod implements /** * @ORM\ManyToOne( - * targetEntity=Comment::class + * targetEntity=Comment::class, + * cascade={"persist"}, * ) * @Groups({"read"}) + * @ORM\JoinColumn(onDelete="SET NULL") */ private ?Comment $pinnedComment = null; diff --git a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod/AccompanyingPeriodWorkEvaluation.php b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod/AccompanyingPeriodWorkEvaluation.php index 07c9f526c..ed3200370 100644 --- a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod/AccompanyingPeriodWorkEvaluation.php +++ b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod/AccompanyingPeriodWorkEvaluation.php @@ -124,6 +124,7 @@ class AccompanyingPeriodWorkEvaluation implements TrackCreationInterface, TrackU /** * @ORM\Column(type="date_immutable", nullable=true, options={"default": null}) * @Serializer\Groups({"read", "docgen:read"}) + * @Serializer\Groups({"write"}) * @Serializer\Groups({"accompanying_period_work_evaluation:create"}) */ private ?DateTimeImmutable $maxDate = null; diff --git a/src/Bundle/ChillPersonBundle/Entity/Person.php b/src/Bundle/ChillPersonBundle/Entity/Person.php index fab4b2845..fd07b0489 100644 --- a/src/Bundle/ChillPersonBundle/Entity/Person.php +++ b/src/Bundle/ChillPersonBundle/Entity/Person.php @@ -109,6 +109,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI * @ORM\OneToMany(targetEntity=AccompanyingPeriodParticipation::class, * mappedBy="person", * cascade={"persist", "remove", "merge", "detach"}) + * @ORM\OrderBy({"startDate": "DESC"}) */ private $accompanyingPeriodParticipations; diff --git a/src/Bundle/ChillPersonBundle/Entity/PersonPhone.php b/src/Bundle/ChillPersonBundle/Entity/PersonPhone.php index f33646f09..7f0e28ddb 100644 --- a/src/Bundle/ChillPersonBundle/Entity/PersonPhone.php +++ b/src/Bundle/ChillPersonBundle/Entity/PersonPhone.php @@ -98,7 +98,8 @@ class PersonPhone public function isEmpty(): bool { - return empty($this->getDescription()) && empty($this->getPhonenumber()); + return ('' === $this->getDescription() || null === $this->getDescription()) + && null === $this->getPhonenumber(); } public function setDate(DateTime $date): void diff --git a/src/Bundle/ChillPersonBundle/Entity/SocialWork/Evaluation.php b/src/Bundle/ChillPersonBundle/Entity/SocialWork/Evaluation.php index 1350ae6d8..9349c335e 100644 --- a/src/Bundle/ChillPersonBundle/Entity/SocialWork/Evaluation.php +++ b/src/Bundle/ChillPersonBundle/Entity/SocialWork/Evaluation.php @@ -62,6 +62,12 @@ class Evaluation */ private array $title = []; + /** + * @ORM\Column(type="text", nullable=true) + * @Serializer\Groups({"read", "docgen:read"}) + */ + private ?string $url = null; + public function __construct() { $this->socialActions = new ArrayCollection(); @@ -101,6 +107,11 @@ class Evaluation return $this->title; } + public function getUrl(): ?string + { + return $this->url; + } + public function removeSocialAction(SocialAction $socialAction): self { if ($this->socialActions->contains($socialAction)) { @@ -130,4 +141,11 @@ class Evaluation return $this; } + + public function setUrl(?string $url): self + { + $this->url = $url; + + return $this; + } } diff --git a/src/Bundle/ChillPersonBundle/Form/PersonType.php b/src/Bundle/ChillPersonBundle/Form/PersonType.php index acc65c6ac..68ebd3ef4 100644 --- a/src/Bundle/ChillPersonBundle/Form/PersonType.php +++ b/src/Bundle/ChillPersonBundle/Form/PersonType.php @@ -27,6 +27,7 @@ use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\PersonPhone; use Chill\PersonBundle\Form\Type\GenderType; use Chill\PersonBundle\Form\Type\PersonAltNameType; +use Chill\PersonBundle\Form\Type\PersonPhoneType; use Chill\PersonBundle\Form\Type\Select2MaritalStatusType; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\Form\AbstractType; @@ -158,7 +159,7 @@ class PersonType extends AbstractType } $builder->add('otherPhoneNumbers', ChillCollectionType::class, [ - 'entry_type' => ChillPhoneNumberType::class, + 'entry_type' => PersonPhoneType::class, 'button_add_label' => 'Add new phone', 'button_remove_label' => 'Remove phone', 'required' => false, diff --git a/src/Bundle/ChillPersonBundle/Form/Type/PersonPhoneType.php b/src/Bundle/ChillPersonBundle/Form/Type/PersonPhoneType.php index 00243349f..3b1de28c7 100644 --- a/src/Bundle/ChillPersonBundle/Form/Type/PersonPhoneType.php +++ b/src/Bundle/ChillPersonBundle/Form/Type/PersonPhoneType.php @@ -11,11 +11,11 @@ declare(strict_types=1); namespace Chill\PersonBundle\Form\Type; +use Chill\MainBundle\Form\Type\ChillPhoneNumberType; use Chill\MainBundle\Phonenumber\PhonenumberHelper; use Chill\PersonBundle\Entity\PersonPhone; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\TelType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; @@ -36,7 +36,7 @@ class PersonPhoneType extends AbstractType public function buildForm(FormBuilderInterface $builder, array $options) { - $builder->add('phonenumber', TelType::class, [ + $builder->add('phonenumber', ChillPhoneNumberType::class, [ 'label' => 'Other phonenumber', 'required' => true, ]); diff --git a/src/Bundle/ChillPersonBundle/Resources/public/chill/chillperson.scss b/src/Bundle/ChillPersonBundle/Resources/public/chill/chillperson.scss index 30eadc7b1..fba0516a7 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/chill/chillperson.scss +++ b/src/Bundle/ChillPersonBundle/Resources/public/chill/chillperson.scss @@ -33,61 +33,74 @@ div.banner { padding-top: 1em; padding-bottom: 1em; div.contact { + display: flex; + align-content: center; & > * { margin-right: 1em; } } + .household-link { + border: 1px solid white; + padding: .05rem .3rem; + border-radius: 5px; + color: white; + cursor: pointer; + &:hover { + background-color: white; + color: $chill-person-context + } + } } } div.person-view { - figure.person-details { - h2 { - font-family: 'Open Sans'; - font-weight: 600; - margin-bottom: 0.3em; - font-variant: small-caps; - } - dl { - margin-top: 0.3em; - } - dt { - font-family: 'Open Sans'; - font-weight: 600; - } - dd { - margin-left: 0; - } - /* - a.sc-button { background-color: $black; padding-top: 0.2em; padding-bottom: 0.2em; } - */ - } - /* custom fields on the home page */ - div.custom-fields { - figure.person-details { - display: flex; - flex-flow: row wrap; - div.cf_title_box:nth-child(4n+1) h2 { - @extend .chill-red !optional; - } - div.cf_title_box:nth-child(4n+2) h2 { - @extend .chill-green !optional; - } - div.cf_title_box:nth-child(4n+3) h2 { - @extend .chill-orange !optional; - } - div.cf_title_box:nth-child(4n+4) h2 { - @extend .chill-blue !optional; - } - div.cf_title_box:nth-child(2n+1) { - width: 50%; - margin-right: 40px; - } - div.cf_title_box:nth-child(2n+2) { - width: calc(50% - 40px); - } - } - } + figure.person-details { + h2 { + font-family: 'Open Sans'; + font-weight: 600; + margin-bottom: 0.3em; + font-variant: small-caps; + } + dl { + margin-top: 0.3em; + } + dt { + font-family: 'Open Sans'; + font-weight: 600; + } + dd { + margin-left: 0; + } + /* + a.sc-button { background-color: $black; padding-top: 0.2em; padding-bottom: 0.2em; } + */ + } + /* custom fields on the home page */ + div.custom-fields { + figure.person-details { + display: flex; + flex-flow: row wrap; + div.cf_title_box:nth-child(4n+1) h2 { + @extend .chill-red !optional; + } + div.cf_title_box:nth-child(4n+2) h2 { + @extend .chill-green !optional; + } + div.cf_title_box:nth-child(4n+3) h2 { + @extend .chill-orange !optional; + } + div.cf_title_box:nth-child(4n+4) h2 { + @extend .chill-blue !optional; + } + div.cf_title_box:nth-child(2n+1) { + width: 50%; + margin-right: 40px; + } + div.cf_title_box:nth-child(2n+2) { + width: calc(50% - 40px); + } + } + } } /* diff --git a/src/Bundle/ChillPersonBundle/Resources/public/mod/AccompanyingPeriod/setReferrer.js b/src/Bundle/ChillPersonBundle/Resources/public/mod/AccompanyingPeriod/setReferrer.js index b73c0afea..939075380 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/mod/AccompanyingPeriod/setReferrer.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/mod/AccompanyingPeriod/setReferrer.js @@ -19,22 +19,21 @@ import {fetchResults} from 'ChillMainAssets/lib/api/apiMethods.js'; */ document.querySelectorAll('[data-set-referrer-app]').forEach(function (el) { - let - periodId = Number.parseInt(el.dataset.setReferrerAccompanyingPeriodId); - + const periodId = Number.parseInt(el.dataset.setReferrerAccompanyingPeriodId); + const jobId = Number.parseInt(el.dataset.setReferrerJobId); const url = `/api/1.0/person/accompanying-course/${periodId}/referrers-suggested.json`; fetchResults(url).then(suggested => { - + const filteredSuggested = suggested.filter((s) => s.user_job ? s.user_job.id === jobId : false); const app = createApp({ components: { SetReferrer, }, template: - '', + '', data() { return { - periodId, suggested, original: suggested, + periodId, filteredSuggested, original: filteredSuggested, } }, methods: { @@ -56,7 +55,7 @@ document.querySelectorAll('[data-set-referrer-app]').forEach(function (el) { label.textContent = ref.text; label.classList.remove('chill-no-data-statement'); - this.suggested = this.original.filter(user => user.id !== ref.id); + this.filteredSuggested = this.original.filter(user => user.id !== ref.id); } } }); diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Comment.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Comment.vue index a852d71a0..8ae7dfff4 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Comment.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Comment.vue @@ -14,24 +14,27 @@ -
    - {{ $t('comment.created_by', [ - pinnedComment.creator.text, - $d(pinnedComment.createdAt.datetime, 'long') - ]) }} +
    + +
    + +
    @@ -180,22 +172,6 @@ export default { } this.$store.commit('setAddressContext', context); }, - removeAddress() { - let payload = { - target: this.context.target.name, - targetId: this.context.target.id, - locationStatusTo: 'none' - }; - //console.log('remove address'); - this.$store.dispatch('updateLocation', payload) - .catch(({name, violations}) => { - if (name === 'ValidationException' || name === 'AccessException') { - violations.forEach((violation) => this.$toast.open({message: violation})); - } else { - this.$toast.open({message: 'An error occurred'}) - } - }); - }, displayErrors() { return this.$refs.addAddress.errorMsg; }, diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Requestor.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Requestor.vue index 713693605..7cf0dcecc 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Requestor.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Requestor.vue @@ -9,7 +9,7 @@ {{ $t('requestor.is_anonymous') }} - +