diff --git a/CHANGELOG.md b/CHANGELOG.md index c24f76df4..a0612475a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,9 @@ and this project adheres to * address reference: add index for refid * [accompanyingCourse_work] fix styles conflicts + fix bug with remove goal (remove goals one at a time) * [accompanyingCourse] improve masonry on resume page, add origin +* [notification] new notification interface, can be associated to AccompanyingCourse/Period, Activities. + * List notifications, show, and comment in User section + * Notify button and contextual notification box on associated objects pages ## Test releases diff --git a/composer.json b/composer.json index 4a93a9f41..d3766bb65 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,8 @@ "symfony/form": "^4.4", "symfony/framework-bundle": "^4.4", "symfony/intl": "^4.4", - "symfony/mime": "^4.4", + "symfony/mailer": "^5.4", + "symfony/mime": "^5.4", "symfony/monolog-bundle": "^3.5", "symfony/security-bundle": "^4.4", "symfony/serializer": "^5.3", @@ -47,7 +48,12 @@ "symfony/yaml": "^4.4", "twig/extra-bundle": "^3.0", "twig/intl-extra": "^3.0", - "twig/markdown-extra": "^3.3" + "twig/markdown-extra": "^3.3", + "twig/string-extra": "^3.3", + "twig/twig": "^3.0" + }, + "conflict": { + "symfony/symfony": "*" }, "require-dev": { "doctrine/doctrine-fixtures-bundle": "^3.3", @@ -65,8 +71,17 @@ "symfony/var-dumper": "^4.4", "symfony/web-profiler-bundle": "^4.4" }, - "conflict": { - "symfony/symfony": "*" + "config": { + "bin-dir": "bin", + "optimize-autoloader": true, + "sort-packages": true, + "vendor-dir": "tests/app/vendor", + "allow-plugins": { + "composer/package-versions-deprecated": true, + "phpstan/extension-installer": true, + "ergebnis/composer-normalize": true, + "phpro/grumphp": true + } }, "autoload": { "psr-4": { diff --git a/src/Bundle/ChillActivityBundle/Notification/ActivityNotificationHandler.php b/src/Bundle/ChillActivityBundle/Notification/ActivityNotificationHandler.php new file mode 100644 index 000000000..f391de1ff --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Notification/ActivityNotificationHandler.php @@ -0,0 +1,45 @@ +activityRepository = $activityRepository; + } + + public function getTemplate(Notification $notification, array $options = []): string + { + return '@ChillActivity/Activity/showInNotification.html.twig'; + } + + public function getTemplateData(Notification $notification, array $options = []): array + { + return [ + 'notification' => $notification, + 'activity' => $this->activityRepository->find($notification->getRelatedEntityId()), + ]; + } + + public function supports(Notification $notification, array $options = []): bool + { + return $notification->getRelatedEntityClass() === Activity::class; + } +} diff --git a/src/Bundle/ChillActivityBundle/Notification/ActivityNotificationRenderer.php b/src/Bundle/ChillActivityBundle/Notification/ActivityNotificationRenderer.php deleted file mode 100644 index f7f19e3f1..000000000 --- a/src/Bundle/ChillActivityBundle/Notification/ActivityNotificationRenderer.php +++ /dev/null @@ -1,33 +0,0 @@ - $notification]; - } - - public function supports(Notification $notification, array $options = []): bool - { - return $notification->getRelatedEntityClass() === Activity::class; - } -} diff --git a/src/Bundle/ChillActivityBundle/Resources/views/Activity/_list_item.html.twig b/src/Bundle/ChillActivityBundle/Resources/views/Activity/_list_item.html.twig new file mode 100644 index 000000000..7b46ffb67 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Resources/views/Activity/_list_item.html.twig @@ -0,0 +1,151 @@ +{% set t = activity.type %} +
+ +
+
+
+
+ {% if activity.date %} +

+ {{ activity.date|format_date('short') }} +

+ {% endif %} +
+
+

+ + + {{ activity.type.name | localize_translatable_string }} + + {% if activity.emergency %} + {{ 'Emergency'|trans|upper }} + {% endif %} + +

+
+
+
+
+ +
+
+ {% if activity.location and t.locationVisible %} +
+

{{ 'location'|trans }}

+
+

+ {{ activity.location.locationType.title|localize_translatable_string }} + {{ activity.location.name }} +

+
+
+ {% endif %} + + {% if activity.sentReceived is not empty and t.sentReceivedVisible %} +
+

{{ 'Sent received'|trans }}

+
+

+ {{ activity.sentReceived|capitalize|trans }} +

+
+
+ {% endif %} + + {% if activity.user and t.userVisible %} +
+

{{ 'Referrer'|trans }}

+
+

+ {{ activity.user.usernameCanonical|chill_entity_render_string|capitalize }} +

+
+
+ {% endif %} +
+ + {% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with { + 'context': context, + 'with_display': 'wrap-list', + 'entity': activity, + 'badge_person': true + } %} + +
+ {%- if activity.reasons is not empty and t.reasonsVisible -%} +
+
+

{{ 'Reasons'|trans }}

+
+
+ {% for r in activity.reasons %} +

+ {{ r|chill_entity_render_box }} +

+ {% endfor %} +
+
+ {% endif %} + + {%- if activity.socialIssues is not empty and t.socialIssuesVisible -%} +
+
+

{{ 'Social issues'|trans }}

+
+
+ {% for r in activity.socialIssues %} + + {% endfor %} +
+
+ {% endif %} + + {%- if activity.socialActions is not empty and t.socialActionsVisible -%} +
+
+

{{ 'Social actions'|trans }}

+
+
+ {% for r in activity.socialActions %} + + {% endfor %} +
+
+ {% endif %} + + {% if activity.comment.comment is not empty and is_granted('CHILL_ACTIVITY_SEE_DETAILS', activity) %} +
+
+

{{ 'Comment'|trans }}

+
+
+ {{ activity.comment|chill_entity_render_box({ + 'disable_markdown': false, + 'limit_lines': 3, + 'metadata': false + }) }} +
+
+ {% endif %} + + {# Only if ACL SEE_DETAILS AND/OR only on template SHOW ?? + durationTime + travelTime + comment + documents + attendee + #} +
+
+ +
+ +
+ +
diff --git a/src/Bundle/ChillActivityBundle/Resources/views/Activity/list.html.twig b/src/Bundle/ChillActivityBundle/Resources/views/Activity/list.html.twig index 3f49e67cc..a64142863 100644 --- a/src/Bundle/ChillActivityBundle/Resources/views/Activity/list.html.twig +++ b/src/Bundle/ChillActivityBundle/Resources/views/Activity/list.html.twig @@ -1,3 +1,61 @@ +{% macro recordAction(activity, context = null, person_id = null, accompanying_course_id = null) %} + {% if no_action is not defined or no_action == false %} +
  • + {{ 'notification.Notify'|trans }} +
  • + {% endif %} + {% if context == 'person' and activity.accompanyingPeriod is not empty %} + {# + Disable person_id in following links, for redirect to accompanyingCourse context + #} + {% set person_id = null %} + {% set accompanying_course_id = activity.accompanyingPeriod.id %} +
  • + + + {{ 'Period number %number%'|trans({'%number%': accompanying_course_id}) }} + +
  • + {% endif %} +
  • + +
  • + {% if no_action is not defined or no_action == false %} + {% if is_granted('CHILL_ACTIVITY_UPDATE', activity) %} +
  • + +
  • + {% endif %} + {% if is_granted('CHILL_ACTIVITY_DELETE', activity) %} +
  • + +
  • + {% endif %} + {% endif %} +{% endmacro %} +
    {% if activities|length == 0 %} @@ -8,203 +66,10 @@ {% else %}
    {% for activity in activities %} - {% set t = activity.type %} -
    - -
    -
    -
    -
    - {% if activity.date %} -

    - {{ activity.date|format_date('short') }} -

    - {% endif %} -
    -
    -

    - - - {{ activity.type.name | localize_translatable_string }} - - {% if activity.emergency %} - {{ 'Emergency'|trans|upper }} - {% endif %} - -

    -
    -
    -
    -
    - -
    -
    - {% if activity.location and t.locationVisible %} -
    -

    {{ 'location'|trans }}

    -
    -

    - {{ activity.location.locationType.title|localize_translatable_string }} - {{ activity.location.name }} -

    -
    -
    - {% endif %} - - {% if activity.sentReceived is not empty and t.sentReceivedVisible %} -
    -

    {{ 'Sent received'|trans }}

    -
    -

    - {{ activity.sentReceived|capitalize|trans }} -

    -
    -
    - {% endif %} - - {% if activity.user and t.userVisible %} -
    -

    {{ 'Referrer'|trans }}

    -
    -

    - {{ activity.user.usernameCanonical|chill_entity_render_string|capitalize }} -

    -
    -
    - {% endif %} -
    - - {% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with { - 'context': context, - 'with_display': 'wrap-list', - 'entity': activity, - 'badge_person': true - } %} - -
    - {%- if activity.reasons is not empty and t.reasonsVisible -%} -
    -
    -

    {{ 'Reasons'|trans }}

    -
    -
    - {% for r in activity.reasons %} -

    - {{ r|chill_entity_render_box }} -

    - {% endfor %} -
    -
    - {% endif %} - - {%- if activity.socialIssues is not empty and t.socialIssuesVisible -%} -
    -
    -

    {{ 'Social issues'|trans }}

    -
    -
    - {% for r in activity.socialIssues %} - - {% endfor %} -
    -
    - {% endif %} - - {%- if activity.socialActions is not empty and t.socialActionsVisible -%} -
    -
    -

    {{ 'Social actions'|trans }}

    -
    -
    - {% for r in activity.socialActions %} - - {% endfor %} -
    -
    - {% endif %} - - {% if activity.comment.comment is not empty and is_granted('CHILL_ACTIVITY_SEE_DETAILS', activity) %} -
    -
    -

    {{ 'Comment'|trans }}

    -
    -
    - {{ activity.comment|chill_entity_render_box({ - 'disable_markdown': false, - 'limit_lines': 3, - 'metadata': false - }) }} -
    -
    - {% endif %} - - {# Only if ACL SEE_DETAILS AND/OR only on template SHOW ?? - durationTime - travelTime - comment - documents - attendee - #} -
    -
    - -
    -
      - {% if context == 'person' and activity.accompanyingPeriod is not empty %} - {# - Disable person_id in following links, for redirect to accompanyingCourse context - #} - {% set person_id = null %} - {% set accompanying_course_id = activity.accompanyingPeriod.id %} -
    • - - - {{ 'Period number %number%'|trans({'%number%': accompanying_course_id}) }} - -
    • - {% endif %} -
    • - -
    • - {% if no_action is not defined or no_action == false %} - {% if is_granted('CHILL_ACTIVITY_UPDATE', activity) %} -
    • - -
    • - {% endif %} - {% if is_granted('CHILL_ACTIVITY_DELETE', activity) %} -
    • - -
    • - {% endif %} - {% endif %} -
    -
    - -
    + {% include 'ChillActivityBundle:Activity:_list_item.html.twig' with { + 'context': context, + 'recordAction': _self.recordAction(activity, context, person_id, accompanying_course_id) + } %} {% endfor %}
    {% endif %} diff --git a/src/Bundle/ChillActivityBundle/Resources/views/Activity/listAccompanyingCourse.html.twig b/src/Bundle/ChillActivityBundle/Resources/views/Activity/listAccompanyingCourse.html.twig index 431a06ba6..a666f183d 100644 --- a/src/Bundle/ChillActivityBundle/Resources/views/Activity/listAccompanyingCourse.html.twig +++ b/src/Bundle/ChillActivityBundle/Resources/views/Activity/listAccompanyingCourse.html.twig @@ -4,6 +4,17 @@ {% block title %}{{ 'Activity list' |trans }}{% endblock title %} + +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('mod_notification_toggle_read_status') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('mod_notification_toggle_read_status') }} +{% endblock %} + {% block content %} {% set person_id = null %} diff --git a/src/Bundle/ChillActivityBundle/Resources/views/Activity/listPerson.html.twig b/src/Bundle/ChillActivityBundle/Resources/views/Activity/listPerson.html.twig index 158bd0e1b..ac4266f8c 100644 --- a/src/Bundle/ChillActivityBundle/Resources/views/Activity/listPerson.html.twig +++ b/src/Bundle/ChillActivityBundle/Resources/views/Activity/listPerson.html.twig @@ -20,6 +20,16 @@ {% block title %}{{ 'Activity list' |trans }}{% endblock title %} +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('mod_notification_toggle_read_status') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('mod_notification_toggle_read_status') }} +{% endblock %} + {% block personcontent %} {% set person_id = null %} diff --git a/src/Bundle/ChillActivityBundle/Resources/views/Activity/show.html.twig b/src/Bundle/ChillActivityBundle/Resources/views/Activity/show.html.twig index cb5b135b3..9faacd430 100644 --- a/src/Bundle/ChillActivityBundle/Resources/views/Activity/show.html.twig +++ b/src/Bundle/ChillActivityBundle/Resources/views/Activity/show.html.twig @@ -198,8 +198,8 @@ {% if is_granted('CHILL_ACTIVITY_UPDATE', entity) %} -
  • - +
  • + {{ 'Edit'|trans }}
  • @@ -212,9 +212,3 @@ {% endif %} - diff --git a/src/Bundle/ChillActivityBundle/Resources/views/Activity/showAccompanyingCourse.html.twig b/src/Bundle/ChillActivityBundle/Resources/views/Activity/showAccompanyingCourse.html.twig index b74b315a3..c7fa71f6d 100644 --- a/src/Bundle/ChillActivityBundle/Resources/views/Activity/showAccompanyingCourse.html.twig +++ b/src/Bundle/ChillActivityBundle/Resources/views/Activity/showAccompanyingCourse.html.twig @@ -4,6 +4,16 @@ {% block title 'Show the activity'|trans %} +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('mod_notification_toggle_read_status') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('mod_notification_toggle_read_status') }} +{% endblock %} + {% import 'ChillActivityBundle:ActivityReason:macro.html.twig' as m %} {% block content -%} @@ -11,3 +21,21 @@ {% include 'ChillActivityBundle:Activity:show.html.twig' with {'context': 'accompanyingCourse'} %}
    {% endblock content %} + +{% block block_post_menu %} +
    + +
    + + + {{ 'notification.Notify'|trans }} + +
    + + {% set notifications = chill_list_notifications('Chill\\ActivityBundle\\Entity\\Activity', entity.id) %} + {% if notifications is not empty %} + {{ notifications|raw }} + {% endif %} + +
    +{% endblock %} diff --git a/src/Bundle/ChillActivityBundle/Resources/views/Activity/showInNotification.html.twig b/src/Bundle/ChillActivityBundle/Resources/views/Activity/showInNotification.html.twig index 79badfe26..721538271 100644 --- a/src/Bundle/ChillActivityBundle/Resources/views/Activity/showInNotification.html.twig +++ b/src/Bundle/ChillActivityBundle/Resources/views/Activity/showInNotification.html.twig @@ -1,2 +1,27 @@ +{% macro recordAction(activity) %} +
  • + +
  • +{% endmacro %} -Go to Activity +{% if activity is not null %} +
    + {% if is_granted('CHILL_ACTIVITY_SEE', activity) %} + {% include 'ChillActivityBundle:Activity:_list_item.html.twig' with { + 'recordAction': _self.recordAction(activity), + 'context': 'accompanyingCourse', + 'itemBlocClass': 'bg-chill-llight-gray' + } %} + {% else %} +
    + {{ 'This is the minimal activity data'|trans ~ ': ' ~ activity.id }}
    + {{ 'you are not allowed to see it details'|trans }} +
    + {% endif %} +
    +{% else %} +
    + {{ 'You get notified of an activity which does not exists any more'|trans }} +
    +{% endif %} diff --git a/src/Bundle/ChillActivityBundle/Resources/views/Activity/showPerson.html.twig b/src/Bundle/ChillActivityBundle/Resources/views/Activity/showPerson.html.twig index 5e92f8fa2..fa6cf2ea9 100644 --- a/src/Bundle/ChillActivityBundle/Resources/views/Activity/showPerson.html.twig +++ b/src/Bundle/ChillActivityBundle/Resources/views/Activity/showPerson.html.twig @@ -4,6 +4,16 @@ {% block title 'Show the activity'|trans %} +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('mod_notification_toggle_read_status') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('mod_notification_toggle_read_status') }} +{% endblock %} + {% import 'ChillActivityBundle:ActivityReason:macro.html.twig' as m %} {% block personcontent -%} @@ -11,3 +21,21 @@ {% include 'ChillActivityBundle:Activity:show.html.twig' with {'context': 'person'} %} {% endblock personcontent %} + +{% block block_post_menu %} +
    + +
    + + + {{ 'notification.Notify'|trans }} + +
    + + {% set notifications = chill_list_notifications('Chill\\ActivityBundle\\Entity\\Activity', entity.id) %} + {% if notifications is not empty %} + {{ notifications|raw }} + {% endif %} + +
    +{% endblock %} diff --git a/src/Bundle/ChillActivityBundle/translations/messages.fr.yml b/src/Bundle/ChillActivityBundle/translations/messages.fr.yml index 00715d283..4a21603ca 100644 --- a/src/Bundle/ChillActivityBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillActivityBundle/translations/messages.fr.yml @@ -224,3 +224,7 @@ Aggregate by activity reason: Aggréger par sujet de l'activité Last activities: Les dernières activités See activity in accompanying course context: Voir l'activité dans le contexte du parcours d'accompagnement + +You get notified of an activity which does not exists any more: Cette notification ne correspond pas à une activité valide. +you are not allowed to see it details: La notification fait référence à une activité à laquelle vous n'avez pas accès. +This is the minimal activity data: Activité n° diff --git a/src/Bundle/ChillDocGeneratorBundle/Serializer/Normalizer/CollectionDocGenNormalizer.php b/src/Bundle/ChillDocGeneratorBundle/Serializer/Normalizer/CollectionDocGenNormalizer.php index 55774dd83..cd01a003a 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Serializer/Normalizer/CollectionDocGenNormalizer.php +++ b/src/Bundle/ChillDocGeneratorBundle/Serializer/Normalizer/CollectionDocGenNormalizer.php @@ -26,7 +26,7 @@ class CollectionDocGenNormalizer implements ContextAwareNormalizerInterface, Nor /** * @param Collection $object - * @param null|string $format + * @param string|null $format * * @return array|ArrayObject|bool|float|int|string|void|null */ diff --git a/src/Bundle/ChillDocGeneratorBundle/Serializer/Normalizer/DocGenObjectNormalizer.php b/src/Bundle/ChillDocGeneratorBundle/Serializer/Normalizer/DocGenObjectNormalizer.php index 0feea511d..6851c7225 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Serializer/Normalizer/DocGenObjectNormalizer.php +++ b/src/Bundle/ChillDocGeneratorBundle/Serializer/Normalizer/DocGenObjectNormalizer.php @@ -66,7 +66,7 @@ class DocGenObjectNormalizer implements NormalizerAwareInterface, NormalizerInte if (!$this->classMetadataFactory->hasMetadataFor($classMetadataKey)) { throw new LogicException(sprintf( 'This object does not have metadata: %s. Add groups on this entity to allow to serialize with the format %s and groups %s', - is_object($object) ? get_class($object) : '(todo' /*$context['docgen:expects'],*/, + is_object($object) ? get_class($object) : '(todo' /*$context['docgen:expects'],*/ , $format, implode(', ', ($context['groups'] ?? [])) )); diff --git a/src/Bundle/ChillMainBundle/ChillMainBundle.php b/src/Bundle/ChillMainBundle/ChillMainBundle.php index 2ff7ef862..7a4e563bb 100644 --- a/src/Bundle/ChillMainBundle/ChillMainBundle.php +++ b/src/Bundle/ChillMainBundle/ChillMainBundle.php @@ -22,6 +22,7 @@ use Chill\MainBundle\DependencyInjection\CompilerPass\TimelineCompilerClass; use Chill\MainBundle\DependencyInjection\CompilerPass\WidgetsCompilerPass; use Chill\MainBundle\DependencyInjection\ConfigConsistencyCompilerPass; use Chill\MainBundle\DependencyInjection\RoleProvidersCompilerPass; +use Chill\MainBundle\Notification\NotificationHandlerInterface; use Chill\MainBundle\Routing\LocalMenuBuilderInterface; use Chill\MainBundle\Search\SearchApiInterface; use Chill\MainBundle\Security\ProvideRoleInterface; @@ -29,6 +30,7 @@ use Chill\MainBundle\Security\Resolver\CenterResolverInterface; use Chill\MainBundle\Security\Resolver\ScopeResolverInterface; use Chill\MainBundle\Templating\Entity\ChillEntityRenderInterface; use Chill\MainBundle\Templating\Entity\CompilerPass as RenderEntityCompilerPass; +use Chill\MainBundle\Templating\UI\NotificationCounterInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -50,6 +52,10 @@ class ChillMainBundle extends Bundle ->addTag('chill.render_entity'); $container->registerForAutoconfiguration(SearchApiInterface::class) ->addTag('chill.search_api_provider'); + $container->registerForAutoconfiguration(NotificationHandlerInterface::class) + ->addTag('chill_main.notification_handler'); + $container->registerForAutoconfiguration(NotificationCounterInterface::class) + ->addTag('chill.count_notification.user'); $container->addCompilerPass(new SearchableServicesCompilerPass()); $container->addCompilerPass(new ConfigConsistencyCompilerPass()); diff --git a/src/Bundle/ChillMainBundle/Controller/NotificationApiController.php b/src/Bundle/ChillMainBundle/Controller/NotificationApiController.php new file mode 100644 index 000000000..8751aa50a --- /dev/null +++ b/src/Bundle/ChillMainBundle/Controller/NotificationApiController.php @@ -0,0 +1,87 @@ +entityManager = $entityManager; + $this->security = $security; + } + + /** + * @Route("/{id}/mark/read", name="chill_api_main_notification_mark_read", methods={"POST"}) + */ + public function markAsRead(Notification $notification): JsonResponse + { + return $this->markAs('read', $notification); + } + + /** + * @Route("/{id}/mark/unread", name="chill_api_main_notification_mark_unread", methods={"POST"}) + */ + public function markAsUnread(Notification $notification): JsonResponse + { + return $this->markAs('unread', $notification); + } + + private function markAs(string $target, Notification $notification): JsonResponse + { + if (!$this->security->isGranted(NotificationVoter::NOTIFICATION_TOGGLE_READ_STATUS, $notification)) { + throw new AccessDeniedException('Not allowed to toggle read status of notification'); + } + + $user = $this->security->getUser(); + + if (!$user instanceof User) { + throw new RuntimeException('not possible to mark as read by this user'); + } + + switch ($target) { + case 'read': + $notification->markAsReadBy($user); + + break; + + case 'unread': + $notification->markAsUnreadBy($user); + + break; + + default: + throw new UnexpectedValueException("target not supported: {$target}"); + } + + $this->entityManager->flush(); + + return new JsonResponse(null, JsonResponse::HTTP_ACCEPTED, [], false); + } +} diff --git a/src/Bundle/ChillMainBundle/Controller/NotificationController.php b/src/Bundle/ChillMainBundle/Controller/NotificationController.php index 4eed79740..52f58ed68 100644 --- a/src/Bundle/ChillMainBundle/Controller/NotificationController.php +++ b/src/Bundle/ChillMainBundle/Controller/NotificationController.php @@ -11,59 +11,292 @@ declare(strict_types=1); namespace Chill\MainBundle\Controller; -use Chill\MainBundle\Notification\NotificationRenderer; +use Chill\MainBundle\Entity\Notification; +use Chill\MainBundle\Entity\NotificationComment; +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Form\NotificationCommentType; +use Chill\MainBundle\Form\NotificationType; +use Chill\MainBundle\Notification\Exception\NotificationHandlerNotFound; +use Chill\MainBundle\Notification\NotificationHandlerManager; use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Repository\NotificationRepository; +use Chill\MainBundle\Security\Authorization\NotificationVoter; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Core\Security; +use Symfony\Contracts\Translation\TranslatorInterface; /** * @Route("/{_locale}/notification") */ class NotificationController extends AbstractController { - private $security; + private EntityManagerInterface $em; - public function __construct(Security $security) - { + private NotificationHandlerManager $notificationHandlerManager; + + private NotificationRepository $notificationRepository; + + private PaginatorFactory $paginatorFactory; + + private Security $security; + + private TranslatorInterface $translator; + + public function __construct( + EntityManagerInterface $em, + Security $security, + NotificationRepository $notificationRepository, + NotificationHandlerManager $notificationHandlerManager, + PaginatorFactory $paginatorFactory, + TranslatorInterface $translator + ) { + $this->em = $em; $this->security = $security; + $this->notificationRepository = $notificationRepository; + $this->notificationHandlerManager = $notificationHandlerManager; + $this->paginatorFactory = $paginatorFactory; + $this->translator = $translator; } /** - * @Route("/show", name="chill_main_notification_show") + * @Route("/create", name="chill_main_notification_create") */ - public function showAction( - NotificationRepository $notificationRepository, - NotificationRenderer $notificationRenderer, - PaginatorFactory $paginatorFactory - ) { + public function createAction(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); + + if (!$this->security->getUser() instanceof User) { + throw new AccessDeniedHttpException('You must be authenticated and a user to create a notification'); + } + + if (!$request->query->has('entityClass')) { + throw new BadRequestHttpException('Missing entityClass parameter'); + } + + if (!$request->query->has('entityId')) { + throw new BadRequestHttpException('missing entityId parameter'); + } + + $notification = new Notification(); + $notification + ->setRelatedEntityClass($request->query->get('entityClass')) + ->setRelatedEntityId($request->query->getInt('entityId')) + ->setSender($this->security->getUser()); + + try { + $handler = $this->notificationHandlerManager->getHandler($notification); + } catch (NotificationHandlerNotFound $e) { + throw new BadRequestHttpException('no handler for this notification'); + } + + $form = $this->createForm(NotificationType::class, $notification); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->em->persist($notification); + $this->em->flush(); + + $this->addFlash('success', $this->translator->trans('notification.Notification created')); + + if ($request->query->has('returnPath')) { + return new RedirectResponse($request->query->get('returnPath')); + } + + return $this->redirectToRoute('chill_main_homepage'); + } + + return $this->render('@ChillMain/Notification/create.html.twig', [ + 'form' => $form->createView(), + 'handler' => $handler, + 'notification' => $notification, + ]); + } + + /** + * @Route("/{id}/edit", name="chill_main_notification_edit") + */ + public function editAction(Notification $notification, Request $request): Response + { + $this->denyAccessUnlessGranted(NotificationVoter::NOTIFICATION_UPDATE, $notification); + + $form = $this->createForm(NotificationType::class, $notification); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->em->flush(); + + $this->addFlash('success', $this->translator->trans('notification.Notification updated')); + + if ($request->query->has('returnPath')) { + return new RedirectResponse($request->query->get('returnPath')); + } + + return $this->redirectToRoute('chill_main_notification_my'); + } + + return $this->render('@ChillMain/Notification/edit.html.twig', [ + 'form' => $form->createView(), + 'handler' => $this->notificationHandlerManager->getHandler($notification), + 'notification' => $notification, + ]); + } + + /** + * @Route("/inbox", name="chill_main_notification_my") + */ + public function inboxAction(): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); $currentUser = $this->security->getUser(); - $notificationsNbr = $notificationRepository->countAllForAttendee(($currentUser)); - $paginator = $paginatorFactory->create($notificationsNbr); + $notificationsNbr = $this->notificationRepository->countAllForAttendee(($currentUser)); + $paginator = $this->paginatorFactory->create($notificationsNbr); - $notifications = $notificationRepository->findAllForAttendee( + $notifications = $this->notificationRepository->findAllForAttendee( $currentUser, $limit = $paginator->getItemsPerPage(), $offset = $paginator->getCurrentPage()->getFirstItemNumber() ); + return $this->render('@ChillMain/Notification/list.html.twig', [ + 'datas' => $this->itemsForTemplate($notifications), + 'notifications' => $notifications, + 'paginator' => $paginator, + 'step' => 'inbox', + 'unreads' => $this->countUnread(), + ]); + } + + /** + * @Route("/sent", name="chill_main_notification_sent") + */ + public function sentAction(): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); + $currentUser = $this->security->getUser(); + + $notificationsNbr = $this->notificationRepository->countAllForSender($currentUser); + $paginator = $this->paginatorFactory->create($notificationsNbr); + + $notifications = $this->notificationRepository->findAllForSender( + $currentUser, + $limit = $paginator->getItemsPerPage(), + $offset = $paginator->getCurrentPage()->getFirstItemNumber() + ); + + return $this->render('@ChillMain/Notification/list.html.twig', [ + 'datas' => $this->itemsForTemplate($notifications), + 'notifications' => $notifications, + 'paginator' => $paginator, + 'step' => 'sent', + 'unreads' => $this->countUnread(), + ]); + } + + /** + * @Route("/{id}/show", name="chill_main_notification_show") + */ + public function showAction(Notification $notification, Request $request): Response + { + $this->denyAccessUnlessGranted(NotificationVoter::NOTIFICATION_SEE, $notification); + + if ($request->query->has('edit')) { + $commentId = $request->query->getInt('edit'); + $editedComment = $notification->getComments()->filter(static function (NotificationComment $c) use ($commentId) { + return $c->getId() === $commentId; + })->first(); + + if (false === $editedComment) { + throw $this->createNotFoundException("Comment with id {$commentId} does not exists nor belong to this notification"); + } + + $this->denyAccessUnlessGranted(NotificationVoter::COMMENT_EDIT, $editedComment); + + $editedCommentForm = $this->createForm(NotificationCommentType::class, $editedComment); + + if (Request::METHOD_POST === $request->getMethod() && 'edit' === $request->request->get('form')) { + $editedCommentForm->handleRequest($request); + + if ($editedCommentForm->isSubmitted() && $editedCommentForm->isValid()) { + $this->em->flush(); + + $this->addFlash('success', $this->translator->trans('notification.comment_updated')); + + return $this->redirectToRoute('chill_main_notification_show', [ + 'id' => $notification->getId(), + '_fragment' => 'comment-' . $commentId, + ]); + } + } + } + + if ($this->isGranted(NotificationVoter::COMMENT_ADD, $notification)) { + $appendComment = new NotificationComment(); + $appendCommentForm = $this->createForm(NotificationCommentType::class, $appendComment); + + if (Request::METHOD_POST === $request->getMethod() && 'append' === $request->request->get('form')) { + $appendCommentForm->handleRequest($request); + + if ($appendCommentForm->isSubmitted() && $appendCommentForm->isValid()) { + $notification->addComment($appendComment); + $this->em->persist($appendComment); + $this->em->flush(); + + $this->addFlash('success', $this->translator->trans('notification.comment_appended')); + + return $this->redirectToRoute('chill_main_notification_show', [ + 'id' => $notification->getId(), + ]); + } + } + } + + $response = $this->render('@ChillMain/Notification/show.html.twig', [ + 'notification' => $notification, + 'handler' => $this->notificationHandlerManager->getHandler($notification), + 'appendCommentForm' => isset($appendCommentForm) ? $appendCommentForm->createView() : null, + 'editedCommentForm' => isset($editedCommentForm) ? $editedCommentForm->createView() : null, + 'editedCommentId' => $commentId ?? null, + ]); + + // we mark the notification as read after having computed the response + if ($this->getUser() instanceof User && !$notification->isReadBy($this->getUser())) { + $notification->markAsReadBy($this->getUser()); + $this->em->flush(); + } + + return $response; + } + + private function countUnread(): array + { + return [ + 'sent' => $this->notificationRepository->countUnreadByUserWhereSender($this->security->getUser()), + 'inbox' => $this->notificationRepository->countUnreadByUserWhereAddressee($this->security->getUser()), + ]; + } + + private function itemsForTemplate(array $notifications): array + { $templateData = []; foreach ($notifications as $notification) { - $data = [ - 'template' => $notificationRenderer->getTemplate($notification), - 'template_data' => $notificationRenderer->getTemplateData($notification), + $templateData[] = [ + 'template' => $this->notificationHandlerManager->getTemplate($notification), + 'template_data' => $this->notificationHandlerManager->getTemplateData($notification), 'notification' => $notification, ]; - $templateData[] = $data; } - return $this->render('@ChillMain/Notification/show.html.twig', [ - 'datas' => $templateData, - 'notifications' => $notifications, - 'paginator' => $paginator, - ]); + return $templateData; } } diff --git a/src/Bundle/ChillMainBundle/Entity/Notification.php b/src/Bundle/ChillMainBundle/Entity/Notification.php index cba4f33e5..e566a8d78 100644 --- a/src/Bundle/ChillMainBundle/Entity/Notification.php +++ b/src/Bundle/ChillMainBundle/Entity/Notification.php @@ -11,7 +11,9 @@ declare(strict_types=1); namespace Chill\MainBundle\Entity; +use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; use DateTimeImmutable; +use DateTimeInterface; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -20,19 +22,27 @@ use Doctrine\ORM\Mapping as ORM; * @ORM\Entity * @ORM\Table( * name="chill_main_notification", - * uniqueConstraints={ - * @ORM\UniqueConstraint(columns={"relatedEntityClass", "relatedEntityId"}) - * } * ) + * @ORM\HasLifecycleCallbacks */ -class Notification +class Notification implements TrackUpdateInterface { + private array $addedAddresses = []; + /** * @ORM\ManyToMany(targetEntity=User::class) * @ORM\JoinTable(name="chill_main_notification_addresses_user") */ private Collection $addressees; + private ?ArrayCollection $addressesOnLoad = null; + + /** + * @ORM\OneToMany(targetEntity=NotificationComment::class, mappedBy="notification", orphanRemoval=true) + * @ORM\OrderBy({"createdAt": "ASC"}) + */ + private Collection $comments; + /** * @ORM\Column(type="datetime_immutable") */ @@ -43,43 +53,84 @@ class Notification * @ORM\GeneratedValue * @ORM\Column(type="integer") */ - private int $id; + private ?int $id = null; /** * @ORM\Column(type="text") */ - private string $message; - - /** - * @ORM\Column(type="json") - */ - private array $read; + private string $message = ''; /** * @ORM\Column(type="string", length=255) */ - private string $relatedEntityClass; + private string $relatedEntityClass = ''; /** * @ORM\Column(type="integer") */ private int $relatedEntityId; + private array $removedAddresses = []; + /** * @ORM\ManyToOne(targetEntity=User::class) - * @ORM\JoinColumn(nullable=false) + * @ORM\JoinColumn(nullable=true) */ - private User $sender; + private ?User $sender = null; + + /** + * @ORM\Column(type="text", options={"default": ""}) + */ + private string $title = ''; + + /** + * @ORM\ManyToMany(targetEntity=User::class) + * @ORM\JoinTable(name="chill_main_notification_addresses_unread") + */ + private Collection $unreadBy; + + /** + * @ORM\Column(type="datetime_immutable") + */ + private ?DateTimeImmutable $updatedAt; + + /** + * @ORM\ManyToOne(targetEntity=User::class) + */ + private ?User $updatedBy; public function __construct() { $this->addressees = new ArrayCollection(); + $this->unreadBy = new ArrayCollection(); + $this->comments = new ArrayCollection(); + $this->setDate(new DateTimeImmutable()); } public function addAddressee(User $addressee): self { if (!$this->addressees->contains($addressee)) { $this->addressees[] = $addressee; + $this->addedAddresses[] = $addressee; + } + + return $this; + } + + public function addComment(NotificationComment $comment): self + { + if (!$this->comments->contains($comment)) { + $this->comments[] = $comment; + $comment->setNotification($this); + } + + return $this; + } + + public function addUnreadBy(User $user): self + { + if (!$this->unreadBy->contains($user)) { + $this->unreadBy[] = $user; } return $this; @@ -90,9 +141,19 @@ class Notification */ public function getAddressees(): Collection { + // keep a copy to compute changes later + if (null === $this->addressesOnLoad) { + $this->addressesOnLoad = new ArrayCollection($this->addressees->toArray()); + } + return $this->addressees; } + public function getComments(): Collection + { + return $this->comments; + } + public function getDate(): ?DateTimeImmutable { return $this->date; @@ -108,11 +169,6 @@ class Notification return $this->message; } - public function getRead(): array - { - return $this->read; - } - public function getRelatedEntityClass(): ?string { return $this->relatedEntityClass; @@ -128,9 +184,97 @@ class Notification return $this->sender; } + public function getTitle(): string + { + return $this->title; + } + + public function getUnreadBy(): Collection + { + return $this->unreadBy; + } + + public function getUpdatedAt(): ?DateTimeImmutable + { + return $this->updatedAt; + } + + public function getUpdatedBy(): ?User + { + return $this->updatedBy; + } + + public function isReadBy(User $user): bool + { + return !$this->unreadBy->contains($user); + } + + public function isSystem(): bool + { + return null === $this->sender; + } + + public function markAsReadBy(User $user): self + { + return $this->removeUnreadBy($user); + } + + public function markAsUnreadBy(User $user): self + { + return $this->addUnreadBy($user); + } + + /** + * @ORM\PreFlush + */ + public function registerUnread() + { + foreach ($this->addedAddresses as $addressee) { + $this->addUnreadBy($addressee); + } + + foreach ($this->removedAddresses as $addressee) { + $this->removeAddressee($addressee); + } + + if (null !== $this->addressesOnLoad) { + foreach ($this->addressees as $existingAddresse) { + if (!$this->addressesOnLoad->contains($existingAddresse)) { + $this->addUnreadBy($existingAddresse); + } + } + + foreach ($this->addressesOnLoad as $onLoadAddressee) { + if (!$this->addressees->contains($onLoadAddressee)) { + $this->removeUnreadBy($onLoadAddressee); + } + } + } + + $this->removedAddresses = []; + $this->addedAddresses = []; + $this->addressesOnLoad = null; + } + public function removeAddressee(User $addressee): self { - $this->addressees->removeElement($addressee); + if ($this->addressees->removeElement($addressee)) { + $this->removedAddresses[] = $addressee; + } + + return $this; + } + + public function removeComment(NotificationComment $comment): self + { + $this->comments->removeElement($comment); + + return $this; + } + + public function removeUnreadBy(User $user): self + { + $this->unreadBy->removeElement($user); return $this; } @@ -149,13 +293,6 @@ class Notification return $this; } - public function setRead(array $read): self - { - $this->read = $read; - - return $this; - } - public function setRelatedEntityClass(string $relatedEntityClass): self { $this->relatedEntityClass = $relatedEntityClass; @@ -176,4 +313,25 @@ class Notification return $this; } + + public function setTitle(string $title): Notification + { + $this->title = $title; + + return $this; + } + + public function setUpdatedAt(DateTimeInterface $datetime): self + { + $this->updatedAt = $datetime; + + return $this; + } + + public function setUpdatedBy(User $user): self + { + $this->updatedBy = $user; + + return $this; + } } diff --git a/src/Bundle/ChillMainBundle/Entity/NotificationComment.php b/src/Bundle/ChillMainBundle/Entity/NotificationComment.php new file mode 100644 index 000000000..01911c729 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Entity/NotificationComment.php @@ -0,0 +1,191 @@ +content; + } + + public function getCreatedAt(): ?DateTimeImmutable + { + return $this->createdAt; + } + + public function getCreatedBy(): ?User + { + return $this->createdBy; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getNotification(): ?Notification + { + return $this->notification; + } + + public function getUpdatedAt(): ?DateTimeImmutable + { + return $this->updateAt; + } + + public function getUpdatedBy(): ?User + { + return $this->updatedBy; + } + + /** + * @ORM\PreFlush + */ + public function onFlushMarkNotificationAsUnread(PreFlushEventArgs $eventArgs): void + { + if ($this->recentlyPersisted) { + foreach ($this->getNotification()->getAddressees() as $addressee) { + if ($this->getCreatedBy() !== $addressee) { + $this->getNotification()->markAsUnreadBy($addressee); + } + } + + if ($this->getNotification()->getSender() !== $this->getCreatedBy()) { + $this->getNotification()->markAsUnreadBy($this->getNotification()->getSender()); + } + } + } + + /** + * @ORM\PrePersist + */ + public function onPrePersist(LifecycleEventArgs $eventArgs): void + { + $this->recentlyPersisted = true; + } + + public function setContent(string $content): self + { + $this->content = $content; + + return $this; + } + + public function setCreatedAt(DateTimeInterface $datetime): self + { + $this->createdAt = $datetime; + + return $this; + } + + public function setCreatedBy(User $user): self + { + $this->createdBy = $user; + + return $this; + } + + /** + * @internal use Notification::addComment + */ + public function setNotification(?Notification $notification): self + { + $this->notification = $notification; + + return $this; + } + + /** + * @deprecated use @see{self::setUpdatedAt} instead + */ + public function setUpdateAt(?DateTimeImmutable $updateAt): self + { + return $this->setUpdatedAt($updateAt); + } + + public function setUpdatedAt(DateTimeInterface $datetime): self + { + $this->updateAt = $datetime; + + return $this; + } + + public function setUpdatedBy(User $user): self + { + $this->updatedBy = $user; + + return $this; + } +} diff --git a/src/Bundle/ChillMainBundle/Form/NotificationCommentType.php b/src/Bundle/ChillMainBundle/Form/NotificationCommentType.php new file mode 100644 index 000000000..7aa3a6cb1 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/NotificationCommentType.php @@ -0,0 +1,26 @@ +add('content', ChillTextareaType::class, [ + 'required' => false, + ]); + } +} diff --git a/src/Bundle/ChillMainBundle/Form/NotificationType.php b/src/Bundle/ChillMainBundle/Form/NotificationType.php new file mode 100644 index 000000000..b24513524 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/NotificationType.php @@ -0,0 +1,43 @@ +add('title', TextType::class, [ + 'label' => 'Title', + 'required' => true, + ]) + ->add('addressees', PickUserDynamicType::class, [ + 'multiple' => true, + ]) + ->add('message', ChillTextareaType::class, [ + 'required' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefault('class', Notification::class); + } +} diff --git a/src/Bundle/ChillMainBundle/Form/Type/DataTransformer/UserToJsonTransformer.php b/src/Bundle/ChillMainBundle/Form/Type/DataTransformer/UserToJsonTransformer.php new file mode 100644 index 000000000..df670f891 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/Type/DataTransformer/UserToJsonTransformer.php @@ -0,0 +1,81 @@ +denormalizer = $denormalizer; + $this->serializer = $serializer; + $this->multiple = $multiple; + } + + public function reverseTransform($value) + { + if ($this->multiple) { + return array_map( + function ($item) { return $this->denormalizeOne($item); }, + json_decode($value, true) + ); + } + + return $this->denormalizeOne(json_decode($value, true)); + } + + /** + * @param User|User[] $value + */ + public function transform($value): string + { + if (null === $value) { + return $this->multiple ? 'null' : '[]'; + } + + return $this->serializer->serialize($value, 'json', [ + AbstractNormalizer::GROUPS => ['read'], + ]); + } + + private function denormalizeOne(array $item): User + { + if (!array_key_exists('type', $item)) { + throw new TransformationFailedException('the key "type" is missing on element'); + } + + if (!array_key_exists('id', $item)) { + throw new TransformationFailedException('the key "id" is missing on element'); + } + + return + $this->denormalizer->denormalize( + ['type' => $item['type'], 'id' => $item['id']], + User::class, + 'json', + [AbstractNormalizer::GROUPS => ['read']], + ); + } +} diff --git a/src/Bundle/ChillMainBundle/Form/Type/PickUserDynamicType.php b/src/Bundle/ChillMainBundle/Form/Type/PickUserDynamicType.php new file mode 100644 index 000000000..52798608e --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/Type/PickUserDynamicType.php @@ -0,0 +1,63 @@ +denormalizer = $denormalizer; + $this->serializer = $serializer; + } + + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->addViewTransformer(new UserToJsonTransformer($this->denormalizer, $this->serializer, $options['multiple'])); + } + + public function buildView(FormView $view, FormInterface $form, array $options) + { + $view->vars['multiple'] = $options['multiple']; + $view->vars['types'] = ['user']; + $view->vars['uniqid'] = uniqid('pick_user_dyn'); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver + ->setDefault('multiple', false) + ->setAllowedTypes('multiple', ['bool']) + ->setDefault('compound', false); + } + + public function getBlockPrefix() + { + return 'pick_user_dynamic'; + } +} diff --git a/src/Bundle/ChillMainBundle/Notification/Counter/NotificationByUserCounter.php b/src/Bundle/ChillMainBundle/Notification/Counter/NotificationByUserCounter.php new file mode 100644 index 000000000..8dfd2df8a --- /dev/null +++ b/src/Bundle/ChillMainBundle/Notification/Counter/NotificationByUserCounter.php @@ -0,0 +1,95 @@ +cacheItemPool = $cacheItemPool; + $this->notificationRepository = $notificationRepository; + } + + public function addNotification(UserInterface $u): int + { + if (!$u instanceof User) { + return 0; + } + + return $this->countUnreadByUser($u); + } + + public function countUnreadByUser(User $user): int + { + $key = self::generateCacheKeyUnreadNotificationByUser($user); + + $item = $this->cacheItemPool->getItem($key); + + if ($item->isHit()) { + return $item->get(); + } + + $unreads = $this->notificationRepository->countUnreadByUser($user); + + $item + ->set($unreads) + // keep in cache for 15 minutes + ->expiresAfter(60 * 15); + $this->cacheItemPool->save($item); + + return $unreads; + } + + public static function generateCacheKeyUnreadNotificationByUser(User $user): string + { + return 'chill_main_notif_unread_by_' . $user->getId(); + } + + public function onEditNotificationComment(NotificationComment $notificationComment, LifecycleEventArgs $eventArgs): void + { + $this->resetCacheForNotification($notificationComment->getNotification()); + } + + public function onPreFlushNotification(Notification $notification, PreFlushEventArgs $eventArgs): void + { + $this->resetCacheForNotification($notification); + } + + private function resetCacheForNotification(Notification $notification): void + { + $keys = []; + + if (null !== $notification->getSender()) { + $keys[] = self::generateCacheKeyUnreadNotificationByUser($notification->getSender()); + } + + foreach ($notification->getAddressees() as $addressee) { + $keys[] = self::generateCacheKeyUnreadNotificationByUser($addressee); + } + + $this->cacheItemPool->deleteItems($keys); + } +} diff --git a/src/Bundle/ChillMainBundle/Notification/Email/NotificationMailer.php b/src/Bundle/ChillMainBundle/Notification/Email/NotificationMailer.php new file mode 100644 index 000000000..658764c26 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Notification/Email/NotificationMailer.php @@ -0,0 +1,110 @@ +mailer = $mailer; + $this->logger = $logger; + $this->translator = $translator; + } + + public function postPersistComment(NotificationComment $comment, LifecycleEventArgs $eventArgs): void + { + foreach ( + array_merge( + $comment->getNotification()->getAddressees()->toArray(), + [$comment->getNotification()->getSender()] + ) as $dest + ) { + if (null === $dest->getEmail() || $comment->getCreatedBy() !== $dest) { + continue; + } + $email = new TemplatedEmail(); + $email + ->to($dest->getEmail()) + ->subject('Re: [Chill] ' . $comment->getNotification()->getTitle()) + ->textTemplate('@ChillMain/Notification/email_notification_comment_persist.fr.md.twig') + ->context([ + 'comment' => $comment, + 'dest' => $dest, + ]); + + try { + $this->mailer->send($email); + } catch (TransportExceptionInterface $e) { + $this->logger->warning('[NotificationMailer] could not send an email notification about comment', [ + 'to' => $dest->getEmail(), + 'error_message' => $e->getMessage(), + 'error_trace' => $e->getTraceAsString(), + ]); + } + } + } + + /** + * Send a email after a notification is persisted. + */ + public function postPersistNotification(Notification $notification, LifecycleEventArgs $eventArgs): void + { + foreach ($notification->getAddressees() as $addressee) { + if (null === $addressee->getEmail()) { + continue; + } + + if ($notification->isSystem()) { + $email = new Email(); + $email + ->text($notification->getMessage()) + ->subject('[Chill] ' . $notification->getTitle()); + } else { + $email = new TemplatedEmail(); + $email + ->textTemplate('@ChillMain/Notification/email_non_system_notification_content.fr.md.twig') + ->context([ + 'notification' => $notification, + 'dest' => $addressee, + ]); + } + + $email->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(), + ]); + } + } + } +} diff --git a/src/Bundle/ChillMainBundle/Notification/Exception/NotificationHandlerNotFound.php b/src/Bundle/ChillMainBundle/Notification/Exception/NotificationHandlerNotFound.php new file mode 100644 index 000000000..00add5f4f --- /dev/null +++ b/src/Bundle/ChillMainBundle/Notification/Exception/NotificationHandlerNotFound.php @@ -0,0 +1,18 @@ +handlers = $handlers; + $this->em = $em; + } + + /** + * @throw NotificationHandlerNotFound if handler is not found + */ + public function getHandler(Notification $notification, array $options = []): NotificationHandlerInterface + { + foreach ($this->handlers as $renderer) { + if ($renderer->supports($notification, $options)) { + return $renderer; + } + } + + throw new NotificationHandlerNotFound(); + } + + public function getTemplate(Notification $notification, array $options = []): string + { + return $this->getHandler($notification, $options)->getTemplate($notification, $options); + } + + public function getTemplateData(Notification $notification, array $options = []): array + { + return $this->getHandler($notification, $options)->getTemplateData($notification, $options); + } +} diff --git a/src/Bundle/ChillMainBundle/Notification/NotificationPresence.php b/src/Bundle/ChillMainBundle/Notification/NotificationPresence.php new file mode 100644 index 000000000..91bea3197 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Notification/NotificationPresence.php @@ -0,0 +1,51 @@ +security = $security; + $this->notificationRepository = $notificationRepository; + } + + /** + * @return array|Notification[] + */ + public function getNotificationsForClassAndEntity(string $relatedEntityClass, int $relatedEntityId): array + { + $user = $this->security->getUser(); + + if ($user instanceof User) { + return $this->notificationRepository->findNotificationByRelatedEntityAndUserAssociated( + $relatedEntityClass, + $relatedEntityId, + $user + ); + } + + return []; + } +} diff --git a/src/Bundle/ChillMainBundle/Notification/NotificationRenderer.php b/src/Bundle/ChillMainBundle/Notification/NotificationRenderer.php deleted file mode 100644 index 048dfc02f..000000000 --- a/src/Bundle/ChillMainBundle/Notification/NotificationRenderer.php +++ /dev/null @@ -1,54 +0,0 @@ -renderers[] = $accompanyingPeriodNotificationRenderer; - $this->renderers[] = $activityNotificationRenderer; - } - - public function getTemplate(Notification $notification) - { - return $this->getRenderer($notification)->getTemplate(); - } - - public function getTemplateData(Notification $notification) - { - return $this->getRenderer($notification)->getTemplateData($notification); - } - - private function getRenderer(Notification $notification) - { - foreach ($this->renderers as $renderer) { - if ($renderer->supports($notification)) { - return $renderer; - } - } - - throw new Exception('No renderer for ' . $notification); - } -} diff --git a/src/Bundle/ChillMainBundle/Notification/Templating/NotificationTwigExtension.php b/src/Bundle/ChillMainBundle/Notification/Templating/NotificationTwigExtension.php new file mode 100644 index 000000000..115adf06b --- /dev/null +++ b/src/Bundle/ChillMainBundle/Notification/Templating/NotificationTwigExtension.php @@ -0,0 +1,28 @@ + true, + 'is_safe' => ['html'], + ]), + ]; + } +} diff --git a/src/Bundle/ChillMainBundle/Notification/Templating/NotificationTwigExtensionRuntime.php b/src/Bundle/ChillMainBundle/Notification/Templating/NotificationTwigExtensionRuntime.php new file mode 100644 index 000000000..e35a39ac0 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Notification/Templating/NotificationTwigExtensionRuntime.php @@ -0,0 +1,39 @@ +notificationPresence = $notificationPresence; + } + + public function listNotificationsFor(Environment $environment, string $relatedEntityClass, int $relatedEntityId, array $options = []): string + { + $notifications = $this->notificationPresence->getNotificationsForClassAndEntity($relatedEntityClass, $relatedEntityId); + + if ([] === $notifications) { + return ''; + } + + return $environment->render('@ChillMain/Notification/extension_list_notifications_for.html.twig', [ + 'notifications' => $notifications, + ]); + } +} diff --git a/src/Bundle/ChillMainBundle/Repository/NotificationRepository.php b/src/Bundle/ChillMainBundle/Repository/NotificationRepository.php index 4af19550c..35ec64114 100644 --- a/src/Bundle/ChillMainBundle/Repository/NotificationRepository.php +++ b/src/Bundle/ChillMainBundle/Repository/NotificationRepository.php @@ -13,25 +13,76 @@ namespace Chill\MainBundle\Repository; use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\User; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Query; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ObjectRepository; final class NotificationRepository implements ObjectRepository { + private EntityManagerInterface $em; + private EntityRepository $repository; public function __construct(EntityManagerInterface $entityManager) { + $this->em = $entityManager; $this->repository = $entityManager->getRepository(Notification::class); } - public function countAllForAttendee(User $addressee): int // TODO passer à attendees avec S + public function countAllForAttendee(User $addressee): int { - $query = $this->queryAllForAttendee($addressee, $countQuery = true); + return $this->queryByAddressee($addressee) + ->select('count(n)') + ->getQuery() + ->getSingleScalarResult(); + } - return $query->getSingleScalarResult(); + public function countAllForSender(User $sender): int + { + return $this->queryBySender($sender) + ->select('count(n)') + ->getQuery() + ->getSingleScalarResult(); + } + + public function countUnreadByUser(User $user): int + { + $sql = 'SELECT count(*) AS c FROM chill_main_notification_addresses_unread WHERE user_id = :userId'; + + $rsm = new Query\ResultSetMapping(); + $rsm->addScalarResult('c', 'c', Types::INTEGER); + + $nq = $this->em->createNativeQuery($sql, $rsm) + ->setParameter('userId', $user->getId()); + + return $nq->getSingleScalarResult(); + } + + public function countUnreadByUserWhereAddressee(User $user): int + { + $qb = $this->repository->createQueryBuilder('n'); + $qb + ->select('count(n)') + ->where($qb->expr()->isMemberOf(':user', 'n.addressees')) + ->andWhere($qb->expr()->isMemberOf(':user', 'n.unreadBy')) + ->setParameter('user', $user); + + return $qb->getQuery()->getSingleScalarResult(); + } + + public function countUnreadByUserWhereSender(User $user): int + { + $qb = $this->repository->createQueryBuilder('n'); + $qb + ->select('count(n)') + ->where($qb->expr()->eq('n.sender', ':user')) + ->andWhere($qb->expr()->isMemberOf(':user', 'n.unreadBy')) + ->setParameter('user', $user); + + return $qb->getQuery()->getSingleScalarResult(); } public function find($id, $lockMode = null, $lockVersion = null): ?Notification @@ -53,9 +104,9 @@ final class NotificationRepository implements ObjectRepository * * @return Notification[] */ - public function findAllForAttendee(User $addressee, $limit = null, $offset = null): array // TODO passer à attendees avec S + public function findAllForAttendee(User $addressee, $limit = null, $offset = null): array { - $query = $this->queryAllForAttendee($addressee); + $query = $this->queryByAddressee($addressee)->select('n'); if ($limit) { $query = $query->setMaxResults($limit); @@ -65,7 +116,26 @@ final class NotificationRepository implements ObjectRepository $query = $query->setFirstResult($offset); } - return $query->getResult(); + $query->addOrderBy('n.date', 'DESC'); + + return $query->getQuery()->getResult(); + } + + public function findAllForSender(User $sender, $limit = null, $offset = null): array + { + $query = $this->queryBySender($sender)->select('n'); + + if ($limit) { + $query = $query->setMaxResults($limit); + } + + if ($offset) { + $query = $query->setFirstResult($offset); + } + + $query->addOrderBy('n.date', 'DESC'); + + return $query->getQuery()->getResult(); } /** @@ -79,6 +149,31 @@ final class NotificationRepository implements ObjectRepository return $this->repository->findBy($criteria, $orderBy, $limit, $offset); } + /** + * @return array|Notification[] + */ + public function findNotificationByRelatedEntityAndUserAssociated(string $relatedEntityClass, int $relatedEntityId, User $user): array + { + $qb = $this->repository->createQueryBuilder('n'); + + $qb + ->select('n') + ->where($qb->expr()->eq('n.relatedEntityClass', ':relatedEntityClass')) + ->andWhere($qb->expr()->eq('n.relatedEntityId', ':relatedEntityId')) + ->andWhere($qb->expr()->isNotNull('n.sender')) + ->andWhere( + $qb->expr()->orX( + $qb->expr()->isMemberOf(':user', 'n.addressees'), + $qb->expr()->eq('n.sender', ':user') + ) + ) + ->setParameter('relatedEntityClass', $relatedEntityClass) + ->setParameter('relatedEntityId', $relatedEntityId) + ->setParameter('user', $user); + + return $qb->getQuery()->getResult(); + } + public function findOneBy(array $criteria, ?array $orderBy = null): ?Notification { return $this->repository->findOneBy($criteria, $orderBy); @@ -89,22 +184,25 @@ final class NotificationRepository implements ObjectRepository return Notification::class; } - private function queryAllForAttendee(User $addressee, bool $countQuery = false): Query + private function queryByAddressee(User $addressee, bool $countQuery = false): QueryBuilder { $qb = $this->repository->createQueryBuilder('n'); - $select = 'n'; - - if ($countQuery) { - $select = 'count(n)'; - } - $qb - ->select($select) - ->join('n.addressees', 'a') - ->where('a = :addressee') + ->where($qb->expr()->isMemberOf(':addressee', 'n.addressees')) ->setParameter('addressee', $addressee); - return $qb->getQuery(); + return $qb; + } + + private function queryBySender(User $sender): QueryBuilder + { + $qb = $this->repository->createQueryBuilder('n'); + + $qb + ->where($qb->expr()->eq('n.sender', ':sender')) + ->setParameter('sender', $sender); + + return $qb; } } diff --git a/src/Bundle/ChillMainBundle/Resources/public/chill/chillmain.scss b/src/Bundle/ChillMainBundle/Resources/public/chill/chillmain.scss index e7f613bc8..a570b645a 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/chill/chillmain.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/chill/chillmain.scss @@ -25,6 +25,8 @@ // Chill flex responsive table/block presentation @import './scss/flex_table'; +// Specific templates +@import './scss/notification'; /* * BASE LAYOUT POSITION diff --git a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/buttons.scss b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/buttons.scss index 14c22685b..86e2f2299 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/buttons.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/buttons.scss @@ -20,6 +20,7 @@ $chill-theme-buttons: ( "misc": $gray-300, "cancel": $gray-300, "choose": $gray-300, + "notify": $gray-300, "unlink": $chill-red, ); @@ -73,6 +74,7 @@ $chill-theme-buttons: ( &.btn-delete::before, &.btn-remove::before, &.btn-choose::before, + &.btn-notify::before, &.btn-cancel::before { font: normal normal normal 14px/1 ForkAwesome; margin-right: 0.5em; @@ -98,6 +100,7 @@ $chill-theme-buttons: ( &.btn-cancel::before { content: "\f060"; } // fa-arrow-left &.btn-choose::before { content: "\f00c"; } // fa-check // f046 fa-check-square-o &.btn-unlink::before { content: "\f127"; } // fa-chain-broken + &.btn-notify::before { content: "\f1d8"; } // fa-paper-plane } diff --git a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/notification.scss b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/notification.scss new file mode 100644 index 000000000..23993299d --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/notification.scss @@ -0,0 +1,62 @@ +div.notification { + h2.notification-title, + h6.notification-title { + a { + text-decoration: none; + } + &::before { + font-family: "ForkAwesome"; + font-size: 80%; + margin-right: 0.3em; + } + } + div.read { + h2.notification-title, + h6.notification-title { + font-weight: 500; + &::before { + content: "\f2b7"; //envelope-open-o + } + } + } + div.unread { + h2.notification-title, + h6.notification-title { + &::before { + content: "\f003"; //envelope-o + } + } + } +} + +/* + * Notifications List +*/ + +div.notification-list, +div.notification-show { + div.item-bloc { + div.item-row.header { + + div.item-col { + &:first-child { + flex-grow: 1; + + } + &:last-child { + flex-grow: 0; + } + } + + ul.small_in_title { + list-style-type: circle; + li { + span.item-key { + display: inline-block; + width: 3em; + } + } + } + } + } +} diff --git a/src/Bundle/ChillMainBundle/Resources/public/module/notification/toggle_read.js b/src/Bundle/ChillMainBundle/Resources/public/module/notification/toggle_read.js new file mode 100644 index 000000000..82a118f77 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/module/notification/toggle_read.js @@ -0,0 +1,43 @@ +import {createApp} from "vue"; +import NotificationReadToggle from "ChillMainAssets/vuejs/_components/Notification/NotificationReadToggle.vue"; +import { _createI18n } from "ChillMainAssets/vuejs/_js/i18n"; + +const i18n = _createI18n({}); + +window.addEventListener('DOMContentLoaded', function (e) { + document.querySelectorAll('.notification_toggle_read_status') + .forEach(function (el) { + createApp({ + template: '', + components: { + NotificationReadToggle, + }, + data() { + return { + notificationId: +el.dataset.notificationId, + buttonClass: el.dataset.buttonClass, + buttonNoText: 'false' === el.dataset.buttonText, + showUrl: el.dataset.showButtonUrl, + isRead: 1 === +el.dataset.notificationCurrentIsRead, + } + }, + methods: { + onMarkRead() { + this.isRead = false; + }, + onMarkUnread() { + this.isRead = true; + }, + } + }) + .use(i18n) + .mount(el); + }); +}) diff --git a/src/Bundle/ChillMainBundle/Resources/public/module/pick-entity/index.js b/src/Bundle/ChillMainBundle/Resources/public/module/pick-entity/index.js new file mode 100644 index 000000000..83a890cd9 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/module/pick-entity/index.js @@ -0,0 +1,69 @@ +import { createApp } from 'vue'; +import PickEntity from 'ChillMainAssets/vuejs/PickEntity/PickEntity.vue'; +import { _createI18n } from 'ChillMainAssets/vuejs/_js/i18n'; +import { appMessages } from 'ChillMainAssets/vuejs/PickEntity/i18n'; + +const i18n = _createI18n(appMessages); + +window.addEventListener('DOMContentLoaded', function(e) { + + let apps = document.querySelectorAll('[data-module="pick-dynamic"]'); + + apps.forEach(function(el) { + + const + isMultiple = parseInt(el.dataset.multiple) === 1, + input = document.querySelector('[data-input-uniqid="'+ el.dataset.uniqid +'"]'), + picked = isMultiple ? JSON.parse(input.value) : [JSON.parse(input.value)]; + + createApp({ + template: '', + components: { + PickEntity, + }, + data() { + return { + multiple: isMultiple, + types: JSON.parse(el.dataset.types), + picked, + uniqid: el.dataset.uniqid, + } + }, + methods: { + addNewEntity(entity) { + console.log('addNewEntity', entity); + if (this.multiple) { + console.log('adding multiple'); + if (!this.picked.some(el => { + return el.type === entity.type && el.id === entity.id; + })) { + this.picked.push(entity); + input.value = JSON.stringify(this.picked); + } + } else { + if (!this.picked.some(el => { + return el.type === entity.type && el.id === entity.id; + })) { + this.picked.splice(0, this.picked.length); + this.picked.push(entity); + input.value = JSON.stringify(this.picked[0]); + } + } + }, + removeEntity(entity) { + console.log('removeEntity', entity); + this.picked = this.picked.filter(e => !(e.type === entity.type && e.id === entity.id)); + input.value = JSON.stringify(this.picked); + }, + } + }) + .use(i18n) + .mount(el); + }); +}); diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/PickEntity/PickEntity.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/PickEntity/PickEntity.vue new file mode 100644 index 000000000..43f3ebf94 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/PickEntity/PickEntity.vue @@ -0,0 +1,89 @@ + + + diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/PickEntity/i18n.js b/src/Bundle/ChillMainBundle/Resources/public/vuejs/PickEntity/i18n.js new file mode 100644 index 000000000..2c4432218 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/PickEntity/i18n.js @@ -0,0 +1,17 @@ +import { personMessages } from 'ChillPersonAssets/vuejs/_js/i18n'; + +const appMessages = { + fr: { + pick_entity: { + add: 'Ajouter', + modal_title: 'Ajouter des ', + user: 'Utilisateurs', + person: 'Usagers', + thirdparty: 'Tiers', + } + } +} + +Object.assign(appMessages.fr, personMessages.fr); + +export { appMessages }; diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Notification/NotificationReadToggle.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Notification/NotificationReadToggle.vue new file mode 100644 index 000000000..f1b246626 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Notification/NotificationReadToggle.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/src/Bundle/ChillMainBundle/Resources/views/Form/fields.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Form/fields.html.twig index 1426841bb..25f1363c3 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Form/fields.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Form/fields.html.twig @@ -215,3 +215,8 @@ {{ form_widget(form.center) }} {% endif %} {% endblock %} + +{% block pick_user_dynamic_widget %} + +
    +{% endblock %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/_list_item.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/_list_item.html.twig new file mode 100644 index 000000000..8123373f2 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/_list_item.html.twig @@ -0,0 +1,88 @@ +
    +
    +

    + + {{ notification.title }} + +

    +
    +
    + +
    +
      + {% if step is not defined or step == 'inbox' %} +
    • + + + {{ 'notification.from'|trans }} : + + + {% if not notification.isSystem %} + + {{ notification.sender|chill_entity_render_string }} + + {% else %} + {{ 'notification.is_system'|trans }} + {% endif %} +
    • + {% endif %} + {% if notification.addressees|length > 0 %} +
    • + + + {{ 'notification.to'|trans }} : + + + {% for a in notification.addressees %} + + {{ a|chill_entity_render_string }} + + {% endfor %} +
    • + {% endif %} +
    +
    + +
    + {{ notification.date|format_datetime('long', 'short') }} +
    + +
    +
    +
    + {% include data.template with data.template_data %} +
    +
    +
    +
    +
    + {{ notification.message|u.truncate(250, '…', false)|chill_markdown_to_html }} +
    +
    +
    + {% if action_button is not defined or action_button != 'false' %} +
    + +
    + {% endif %} +
    diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/create.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/create.html.twig new file mode 100644 index 000000000..8797c276a --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/create.html.twig @@ -0,0 +1,46 @@ +{% extends "@ChillMain/layout.html.twig" %} + +{% block title 'notification.Notify'|trans %} + +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('mod_pickentity_type') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('mod_pickentity_type') }} +{% endblock %} + +{% block content %} +
    +

    {{ block('title') }}

    + + {{ form_start(form, { 'attr': { 'id': 'notification' }}) }} + + {{ form_row(form.title, { 'label': 'notification.subject'|trans }) }} + {{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }} + + {% include handler.template(notification) with handler.templateData(notification) %} + +
    + +
    + {{ form_widget(form.message) }} +
    +
    + + {{ form_end(form) }} + + +
    +{% endblock %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/edit.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/edit.html.twig new file mode 100644 index 000000000..b51cc4dab --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/edit.html.twig @@ -0,0 +1,44 @@ +{% extends "@ChillMain/layout.html.twig" %} + +{% block title 'notification.Edit notification'|trans %} + +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('mod_pickentity_type') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('mod_pickentity_type') }} +{% endblock %} + +{% block content %} +
    +

    {{ block('title') }}

    + + {{ form_start(form, { 'attr': { 'id': 'notification' }}) }} + + {{ form_row(form.title, { 'label': 'notification.subject'|trans }) }} + {{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }} + + {% include handler.template(notification) with handler.templateData(notification) %} + +
    + +
    + {{ form_widget(form.message) }} +
    +
    + + {{ form_end(form) }} + + +
    +{% endblock %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content.fr.md.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content.fr.md.twig new file mode 100644 index 000000000..62e41860b --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content.fr.md.twig @@ -0,0 +1,20 @@ +{{ dest.label }}, + +{{ notification.sender.label }} a créé une notification pour vous: + +> {{ notification.title }} +> +> +{%- for line in notification.message|split("\n") %} +> {{ line }} +{%- if not loop.last %} +> +{%- endif %} +{%- endfor %} + +Vous pouvez visualiser la notification et y répondre ici: + +{{ absolute_url(path('chill_main_notification_show', {'_locale': 'fr', 'id': notification.id }, false)) }} + +-- +Le logiciel Chill diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/email_notification_comment_persist.fr.md.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_notification_comment_persist.fr.md.twig new file mode 100644 index 000000000..b1244da39 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_notification_comment_persist.fr.md.twig @@ -0,0 +1,19 @@ +{{ dest.label }}, + +{{ comment.createdBy.label }} a créé un commentaire sur la notification "{{ comment.notification.title }}". + +Commentaire: + +{% for line in comment.content|split("\n") %} +> {{ line }} +{%- if not loop.last %} +> +{%- endif %} +{%- endfor %} + +Vous pouvez visualiser la notification et y répondre ici: + +{{ absolute_url(path('chill_main_notification_show', {'_locale': 'fr', 'id': comment.notification.id }, false)) }} + +-- +Le logiciel Chill diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/extension_list_notifications_for.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/extension_list_notifications_for.html.twig new file mode 100644 index 000000000..31267e135 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/extension_list_notifications_for.html.twig @@ -0,0 +1,45 @@ +
    +
    +

    {{ 'notification.Sent'|trans }}

    +
    + {# TODO pagination or limit #} + {% for notification in notifications %} +
    + + {% if not notification.isSystem %} + {% if notification.sender == app.user %} + +
    + + {{ notification.date|format_datetime('short','short') }} + + {# Vue component #} + +
    + + {% if notification.addressees|length > 0 %} + {{ 'notification.to'|trans }}: + {% endif %} + + {% for a in notification.addressees %} + + {{ a|chill_entity_render_string }} + + {% endfor %} + + {% else %} +
    {{ 'notification.you were notified by %sender%'|trans({'%sender%': notification.sender|chill_entity_render_string }) }}
    + {% endif %} + {% else %} +
    {{ 'notification.you were notified by system'|trans }}
    + {% endif %} + +
    + {% endfor %} +
    diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/list.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/list.html.twig new file mode 100644 index 000000000..cd0cc5b67 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/list.html.twig @@ -0,0 +1,57 @@ +{% extends "@ChillMain/layout.html.twig" %} + +{% block title 'notification.My own notifications'|trans %} + +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('mod_notification_toggle_read_status') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('mod_notification_toggle_read_status') }} +{% endblock %} + +{% block content %} +
    +

    {{ block('title') }}

    + + + + {% if datas|length == 0 %} + {% if step == 'inbox' %} +

    {{ 'notification.Any notification received'|trans }}

    + {% else %} +

    {{ 'notification.Any notification sent'|trans }}

    + {% endif %} + {% else %} +
    + {% for data in datas %} + {% set notification = data.notification %} + {% include 'ChillMainBundle:Notification:_list_item.html.twig' %} + {% endfor %} +
    + {% endif %} +
    +{% endblock content %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/show.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/show.html.twig index 34aeee049..2d3b27728 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Notification/show.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/show.html.twig @@ -1,42 +1,121 @@ {% extends "@ChillMain/layout.html.twig" %} +{% block title 'notification.show notification from %sender%'|trans( + { '%sender%': notification.sender|chill_entity_render_string } +) ~ ' ' ~ notification.title %} + +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('mod_notification_toggle_read_status') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('mod_notification_toggle_read_status') }} +{% endblock %} + +{% import '@ChillPerson/AccompanyingCourse/Comment/macro_showItem.html.twig' as m %} + +{% macro recordAction(comment) %} + {% if is_granted('CHILL_MAIN_NOTIFICATION_COMMENT_EDIT', comment) %} +
  • + +
  • + {% endif %} +{% endmacro %} + {% block content %} -
    -
    -

    {{ "Notifications list" | trans }}

    - +
    - {%for data in datas %} - {% set notification = data.notification %} +

    {{ 'notification.Notification'|trans }}

    -
    -
    {{ 'Message'|trans }}
    -
    {{ notification.message }}
    -
    +
    + {% include 'ChillMainBundle:Notification:_list_item.html.twig' with { + 'data': { + 'template': handler.getTemplate(notification), + 'template_data': handler.getTemplateData(notification) + }, + 'action_button': 'false' + } %} +
    -
    -
    {{ 'Date'|trans }}
    -
    {{ notification.date | date('long') }}
    -
    +
    +

    {{ 'notification.comments_list'|trans }}

    + {% if notification.comments|length > 0 %} +
    + {% for comment in notification.comments %} -
    -
    {{ 'Sender'|trans }}
    -
    {{ notification.sender }}
    -
    + {% if editedCommentForm is null or editedCommentId != comment.id %} + {{ m.show_comment(comment, { + 'recordAction': _self.recordAction(comment) + }) }} + {% else %} +
    +
    + -
    -
    {{ 'Addressees'|trans }}
    -
    {{ notification.addressees |join(', ') }}
    -
    + {{ form_start(editedCommentForm) }} + {{ form_errors(editedCommentForm) }} + {{ form_widget(editedCommentForm.content) }} + + + {{ form_end(editedCommentForm) }} + +
    +
    + {% endif %} + + {% endfor %} +
    + {% endif %} + +
    +

    {{ 'Write a new comment'|trans }}

    + + {{ form_start(appendCommentForm) }} + {{ form_errors(appendCommentForm) }} + {{ form_widget(appendCommentForm.content) }} + +
      +
    • + +
    • +
    + {{ form_end(appendCommentForm) }} -
    -
    {{ 'Entity'|trans }}
    -
    - {% include data.template with data.template_data %} -
    -
    - {% endfor %}
    + + +
    {% endblock content %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig b/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig index 07b7970d8..a123ce405 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig @@ -106,6 +106,6 @@ }); - {% block js%}{% endblock %} + {% block js %}{% endblock %} diff --git a/src/Bundle/ChillMainBundle/Routing/MenuBuilder/UserMenuBuilder.php b/src/Bundle/ChillMainBundle/Routing/MenuBuilder/UserMenuBuilder.php index 51758d979..ae2eb1f2c 100644 --- a/src/Bundle/ChillMainBundle/Routing/MenuBuilder/UserMenuBuilder.php +++ b/src/Bundle/ChillMainBundle/Routing/MenuBuilder/UserMenuBuilder.php @@ -12,16 +12,27 @@ declare(strict_types=1); namespace Chill\MainBundle\Routing\MenuBuilder; use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Notification\Counter\NotificationByUserCounter; use Chill\MainBundle\Routing\LocalMenuBuilderInterface; use Symfony\Component\Security\Core\Security; +use Symfony\Contracts\Translation\TranslatorInterface; class UserMenuBuilder implements LocalMenuBuilderInterface { + private NotificationByUserCounter $notificationByUserCounter; + private Security $security; - public function __construct(Security $security) - { + private TranslatorInterface $translator; + + public function __construct( + NotificationByUserCounter $notificationByUserCounter, + Security $security, + TranslatorInterface $translator + ) { + $this->notificationByUserCounter = $notificationByUserCounter; $this->security = $security; + $this->translator = $translator; } public function buildMenu($menuId, \Knp\Menu\MenuItem $menu, array $parameters) @@ -44,6 +55,20 @@ class UserMenuBuilder implements LocalMenuBuilderInterface 'order' => -9999999, 'icon' => 'map-marker', ]); + + $nbNotifications = $this->notificationByUserCounter->countUnreadByUser($user); + + $menu + ->addChild( + $this->translator->trans('notification.My notifications with counter', ['nb' => $nbNotifications]), + ['route' => 'chill_main_notification_my'] + ) + ->setExtras([ + 'order' => 600, + 'icon' => 'envelope', + 'counter' => $nbNotifications, + ]); + $menu ->addChild( 'Change password', diff --git a/src/Bundle/ChillMainBundle/Security/Authorization/NotificationVoter.php b/src/Bundle/ChillMainBundle/Security/Authorization/NotificationVoter.php new file mode 100644 index 000000000..0a6c239f4 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Security/Authorization/NotificationVoter.php @@ -0,0 +1,89 @@ +getUser(); + + if (!$user instanceof User) { + return false; + } + + if ($subject instanceof Notification) { + switch ($attribute) { + case self::COMMENT_ADD: + return false === $subject->isSystem() && ( + $subject->getAddressees()->contains($user) || $subject->getSender() === $user + ); + + case self::NOTIFICATION_SEE: + case self::NOTIFICATION_TOGGLE_READ_STATUS: + return $subject->getSender() === $user || $subject->getAddressees()->contains($user); + + case self::NOTIFICATION_UPDATE: + return $subject->getSender() === $user && false === $subject->isSystem(); + + default: + throw new UnexpectedValueException("this subject {$attribute} is not implemented"); + } + } elseif ($subject instanceof NotificationComment) { + switch ($attribute) { + case self::COMMENT_ADD: + return false === $subject->getNotification()->isSystem() && ( + $subject->getNotification()->getAddressees()->contains($user) || $subject->getNotification()->getSender() === $user + ); + + case self::COMMENT_EDIT: + return $subject->getCreatedBy() === $user && false === $subject->getNotification()->isSystem(); + + default: + throw new UnexpectedValueException("this subject {$attribute} is not implemented"); + } + } + + throw new UnexpectedValueException(); + } +} diff --git a/src/Bundle/ChillMainBundle/Serializer/Normalizer/AddressNormalizer.php b/src/Bundle/ChillMainBundle/Serializer/Normalizer/AddressNormalizer.php index 998cb58bf..0b46127b6 100644 --- a/src/Bundle/ChillMainBundle/Serializer/Normalizer/AddressNormalizer.php +++ b/src/Bundle/ChillMainBundle/Serializer/Normalizer/AddressNormalizer.php @@ -56,7 +56,7 @@ class AddressNormalizer implements ContextAwareNormalizerInterface, NormalizerAw /** * @param Address $address - * @param null|string $format + * @param string|null $format */ public function normalize($address, $format = null, array $context = []) { diff --git a/src/Bundle/ChillMainBundle/Serializer/Normalizer/CollectionNormalizer.php b/src/Bundle/ChillMainBundle/Serializer/Normalizer/CollectionNormalizer.php index 7ca4d563c..08b22871f 100644 --- a/src/Bundle/ChillMainBundle/Serializer/Normalizer/CollectionNormalizer.php +++ b/src/Bundle/ChillMainBundle/Serializer/Normalizer/CollectionNormalizer.php @@ -22,7 +22,7 @@ class CollectionNormalizer implements NormalizerAwareInterface, NormalizerInterf /** * @param Collection $collection - * @param null|string $format + * @param string|null $format */ public function normalize($collection, $format = null, array $context = []) { diff --git a/src/Bundle/ChillMainBundle/Tests/Controller/NotificationApiControllerTest.php b/src/Bundle/ChillMainBundle/Tests/Controller/NotificationApiControllerTest.php new file mode 100644 index 000000000..96b707ee7 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Controller/NotificationApiControllerTest.php @@ -0,0 +1,98 @@ +get(EntityManagerInterface::class); + + foreach ($this->toDelete as [$className, $id]) { + $object = $em->find($className, $id); + $em->remove($object); + } + + $em->flush(); + } + + public function generateDataMarkAsRead() + { + self::bootKernel(); + $em = self::$container->get(EntityManagerInterface::class); + $userRepository = self::$container->get(UserRepository::class); + $userA = $userRepository->findOneBy(['username' => 'center a_social']); + $userB = $userRepository->findOneBy(['username' => 'center b_social']); + + $notification = new Notification(); + $notification + ->setMessage('Test generated') + ->setRelatedEntityClass(AccompanyingPeriod::class) + ->setRelatedEntityId(0) + ->setSender($userB) + ->addAddressee($userA) + ->setUpdatedAt(new DateTimeImmutable()); + $em->persist($notification); + $em->refresh($notification); + $em->flush(); + + $this->toDelete[] = [Notification::class, $notification->getId()]; + + yield [$notification->getId()]; + } + + /** + * @dataProvider generateDataMarkAsRead + */ + public function testMarkAsReadOrUnRead(int $notificationId) + { + $client = $this->getClientAuthenticated(); + $client->request('POST', "/api/1.0/main/notification/{$notificationId}/mark/read"); + + $this->assertResponseIsSuccessful('test marking as read'); + + $em = self::$container->get(EntityManagerInterface::class); + /** @var Notification $notification */ + $notification = $em->find(Notification::class, $notificationId); + $user = self::$container->get(UserRepository::class)->findOneBy(['username' => 'center a_social']); + + $this->assertTrue($notification->isReadBy($user)); + + $client->request('POST', "/api/1.0/main/notification/{$notificationId}/mark/unread"); + + $this->assertResponseIsSuccessful('test marking as unread'); + + $notification = $em->find(Notification::class, $notificationId); + $user = $em->find(User::class, $user->getId()); + $em->refresh($notification); + $em->refresh($user); + + $this->assertFalse($notification->isReadBy($user)); + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Entity/NotificationTest.php b/src/Bundle/ChillMainBundle/Tests/Entity/NotificationTest.php new file mode 100644 index 000000000..6bf966bbb --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Entity/NotificationTest.php @@ -0,0 +1,123 @@ +get(EntityManagerInterface::class); + + foreach ($this->toDelete as [$className, $id]) { + $object = $em->find($className, $id); + $em->remove($object); + } + + $em->flush(); + } + + public function generateNotificationData() + { + self::bootKernel(); + $userRepository = self::$container->get(UserRepository::class); + + $senderId = $userRepository + ->findOneBy(['username' => 'center b_social']) + ->getId(); + + $addressesIds = []; + $addressesIds[] = $userRepository + ->findOneBy(['username' => 'center b_direction']) + ->getId(); + + yield [ + $senderId, + $addressesIds, + ]; + } + + public function testAddAddresseeStoreAnUread() + { + $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()); + + $this->assertCount(4, $notification->getAddressees()); + + // launch listener + $notification->registerUnread(); + $this->assertCount(4, $notification->getUnreadBy()); + $this->assertContains($user1, $notification->getUnreadBy()->toArray()); + $this->assertContains($user2, $notification->getUnreadBy()->toArray()); + $this->assertContains($user3, $notification->getUnreadBy()->toArray()); + + $notification->markAsReadBy($user1); + + $this->assertCount(3, $notification->getUnreadBy()); + $this->assertNotContains($user1, $notification->getUnreadBy()->toArray()); + } + + /** + * @dataProvider generateNotificationData + */ + public function testPrePersistComputeUnread(int $senderId, array $addressesIds) + { + $em = self::$container->get(EntityManagerInterface::class); + $notification = new Notification(); + $notification + ->setSender($em->find(User::class, $senderId)) + ->setRelatedEntityId(0) + ->setRelatedEntityClass(AccompanyingPeriod::class) + ->setMessage('Fake message'); + + foreach ($addressesIds as $addresseeId) { + $notification + ->getAddressees()->add($em->find(User::class, $addresseeId)); + } + + $em->persist($notification); + $em->flush(); + $em->refresh($notification); + + $this->toDelete[] = [Notification::class, $notification->getId()]; + + $this->assertEquals($senderId, $notification->getSender()->getId()); + $this->assertCount(count($addressesIds), $notification->getUnreadBy()); + + $unreadIds = $notification->getUnreadBy()->map(static function (User $u) { return $u->getId(); }); + + foreach ($addressesIds as $addresseeId) { + $this->assertContains($addresseeId, $unreadIds); + } + } +} diff --git a/src/Bundle/ChillMainBundle/chill.api.specs.yaml b/src/Bundle/ChillMainBundle/chill.api.specs.yaml index db4430e48..753cb855c 100644 --- a/src/Bundle/ChillMainBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillMainBundle/chill.api.specs.yaml @@ -733,4 +733,43 @@ paths: class: 'Chill\PersonBundle\Entity\AccompanyingPeriod' roles: - 'CHILL_PERSON_ACCOMPANYING_PERIOD_SEE' + /1.0/main/notification/{id}/mark/read: + post: + tags: + - notification + summary: mark a notification as read + parameters: + - name: id + in: path + required: true + description: The notification id + schema: + type: integer + format: integer + minimum: 1 + responses: + 202: + description: "accepted" + 403: + description: "unauthorized" + /1.0/main/notification/{id}/mark/unread: + post: + tags: + - notification + summary: mark a notification as unread + parameters: + - name: id + in: path + required: true + description: The notification id + schema: + type: integer + format: integer + minimum: 1 + responses: + 202: + description: "accepted" + 403: + description: "unauthorized" + diff --git a/src/Bundle/ChillMainBundle/chill.webpack.config.js b/src/Bundle/ChillMainBundle/chill.webpack.config.js index dfa113206..47aa32865 100644 --- a/src/Bundle/ChillMainBundle/chill.webpack.config.js +++ b/src/Bundle/ChillMainBundle/chill.webpack.config.js @@ -61,10 +61,11 @@ module.exports = function(encore, entries) encore.addEntry('mod_ckeditor5', __dirname + '/Resources/public/module/ckeditor5/index.js'); encore.addEntry('mod_disablebuttons', __dirname + '/Resources/public/module/disable-buttons/index.js'); encore.addEntry('mod_blur', __dirname + '/Resources/public/module/blur/index.js'); - encore.addEntry('mod_input_address', __dirname + '/Resources/public/vuejs/Address/mod_input_address_index.js'); + encore.addEntry('mod_notification_toggle_read_status', __dirname + '/Resources/public/module/notification/toggle_read.js'); + encore.addEntry('mod_pickentity_type', __dirname + '/Resources/public/module/pick-entity/index.js'); + // Vue entrypoints encore.addEntry('vue_address', __dirname + '/Resources/public/vuejs/Address/index.js'); encore.addEntry('vue_onthefly', __dirname + '/Resources/public/vuejs/OnTheFly/index.js'); - }; diff --git a/src/Bundle/ChillMainBundle/config/services.yaml b/src/Bundle/ChillMainBundle/config/services.yaml index 8ee24d7b7..adf38988e 100644 --- a/src/Bundle/ChillMainBundle/config/services.yaml +++ b/src/Bundle/ChillMainBundle/config/services.yaml @@ -18,8 +18,8 @@ services: Chill\MainBundle\Form\Type\: resource: '../Form/Type' - tags: - - { name: form.type } + autoconfigure: true + autowire: true Chill\MainBundle\Doctrine\Event\: resource: '../Doctrine/Event/' diff --git a/src/Bundle/ChillMainBundle/config/services/notification.yaml b/src/Bundle/ChillMainBundle/config/services/notification.yaml index efb25c5b5..8bfb41ad7 100644 --- a/src/Bundle/ChillMainBundle/config/services/notification.yaml +++ b/src/Bundle/ChillMainBundle/config/services/notification.yaml @@ -13,4 +13,58 @@ services: $translator: '@Symfony\Component\Translation\TranslatorInterface' $routeParameters: '%chill_main.notifications%' - Chill\MainBundle\Notification\NotificationRenderer: ~ + Chill\MainBundle\Notification\NotificationHandlerManager: + arguments: + $handlers: !tagged_iterator chill_main.notification_handler + + Chill\MainBundle\Notification\NotificationPresence: ~ + + Chill\MainBundle\Notification\Templating\NotificationTwigExtension: ~ + + Chill\MainBundle\Notification\Templating\NotificationTwigExtensionRuntime: ~ + + Chill\MainBundle\Notification\Counter\NotificationByUserCounter: + autoconfigure: true + autowire: true + tags: + - + name: 'doctrine.orm.entity_listener' + event: 'preFlush' + entity: 'Chill\MainBundle\Entity\Notification' + # set the 'lazy' option to TRUE to only instantiate listeners when they are used + lazy: true + method: 'onPreFlushNotification' + + - + name: 'doctrine.orm.entity_listener' + event: 'postUpdate' + entity: 'Chill\MainBundle\Entity\NotificationComment' + # set the 'lazy' option to TRUE to only instantiate listeners when they are used + lazy: true + method: 'onEditNotificationComment' + - + name: 'doctrine.orm.entity_listener' + event: 'postPersist' + entity: 'Chill\MainBundle\Entity\NotificationComment' + # set the 'lazy' option to TRUE to only instantiate listeners when they are used + lazy: true + method: 'onEditNotificationComment' + + Chill\MainBundle\Notification\Email\NotificationMailer: + autowire: true + autoconfigure: true + tags: + - + name: 'doctrine.orm.entity_listener' + event: 'postPersist' + entity: 'Chill\MainBundle\Entity\Notification' + # set the 'lazy' option to TRUE to only instantiate listeners when they are used + lazy: true + method: 'postPersistNotification' + - + name: 'doctrine.orm.entity_listener' + event: 'postPersist' + entity: 'Chill\MainBundle\Entity\NotificationComment' + # set the 'lazy' option to TRUE to only instantiate listeners when they are used + lazy: true + method: 'postPersistComment' diff --git a/src/Bundle/ChillMainBundle/config/services/security.yaml b/src/Bundle/ChillMainBundle/config/services/security.yaml index 163b86d8e..30eed05c0 100644 --- a/src/Bundle/ChillMainBundle/config/services/security.yaml +++ b/src/Bundle/ChillMainBundle/config/services/security.yaml @@ -24,6 +24,8 @@ services: Chill\MainBundle\Security\Authorization\DefaultVoterHelperFactory: ~ + Chill\MainBundle\Security\Authorization\NotificationVoter: ~ + Chill\MainBundle\Security\Authorization\VoterHelperFactoryInterface: '@Chill\MainBundle\Security\Authorization\DefaultVoterHelperFactory' chill.main.security.authorization.helper: diff --git a/src/Bundle/ChillMainBundle/migrations/Version20211225231532.php b/src/Bundle/ChillMainBundle/migrations/Version20211225231532.php new file mode 100644 index 000000000..eabcce641 --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20211225231532.php @@ -0,0 +1,39 @@ +addSql('DROP TABLE chill_main_notification_addresses_unread'); + $this->addSql('ALTER TABLE chill_main_notification ADD read JSONB DEFAULT \'[]\''); + } + + public function getDescription(): string + { + return 'Store notification readed by user in a specific table'; + } + + public function up(Schema $schema): void + { + $this->addSql('CREATE TABLE chill_main_notification_addresses_unread (notification_id INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY(notification_id, user_id))'); + $this->addSql('CREATE INDEX IDX_154A075FEF1A9D84 ON chill_main_notification_addresses_unread (notification_id)'); + $this->addSql('CREATE INDEX IDX_154A075FA76ED395 ON chill_main_notification_addresses_unread (user_id)'); + $this->addSql('ALTER TABLE chill_main_notification_addresses_unread ADD CONSTRAINT FK_154A075FEF1A9D84 FOREIGN KEY (notification_id) REFERENCES chill_main_notification (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_main_notification_addresses_unread ADD CONSTRAINT FK_154A075FA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_main_notification DROP read'); + } +} diff --git a/src/Bundle/ChillMainBundle/migrations/Version20211228183221.php b/src/Bundle/ChillMainBundle/migrations/Version20211228183221.php new file mode 100644 index 000000000..b74579959 --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20211228183221.php @@ -0,0 +1,34 @@ +addSql('create unique index uniq_5bdc8067567988b4440f6072 + on chill_main_notification (relatedentityclass, relatedentityid)'); + } + + public function getDescription(): string + { + return 'remove unique index which prevent to notify twice an entity'; + } + + public function up(Schema $schema): void + { + $this->addSql('DROP INDEX uniq_5bdc8067567988b4440f6072'); + } +} diff --git a/src/Bundle/ChillMainBundle/migrations/Version20211228215919.php b/src/Bundle/ChillMainBundle/migrations/Version20211228215919.php new file mode 100644 index 000000000..12e7b747d --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20211228215919.php @@ -0,0 +1,43 @@ +addSql('DROP SEQUENCE chill_main_notification_comment_id_seq CASCADE'); + $this->addSql('DROP TABLE chill_main_notification_comment'); + } + + public function getDescription(): string + { + return 'Notifications: add comment on notifications'; + } + + public function up(Schema $schema): void + { + $this->addSql('CREATE SEQUENCE chill_main_notification_comment_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE chill_main_notification_comment (id INT NOT NULL, notification_id INT NOT NULL, content TEXT NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updateAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, createdBy_id INT DEFAULT NULL, updatedBy_id INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_983BD2CFEF1A9D84 ON chill_main_notification_comment (notification_id)'); + $this->addSql('CREATE INDEX IDX_983BD2CF3174800F ON chill_main_notification_comment (createdBy_id)'); + $this->addSql('CREATE INDEX IDX_983BD2CF65FF1AEC ON chill_main_notification_comment (updatedBy_id)'); + $this->addSql('COMMENT ON COLUMN chill_main_notification_comment.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_main_notification_comment.updateAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_main_notification_comment ADD CONSTRAINT FK_983BD2CFEF1A9D84 FOREIGN KEY (notification_id) REFERENCES chill_main_notification (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_main_notification_comment ADD CONSTRAINT FK_983BD2CF3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_main_notification_comment ADD CONSTRAINT FK_983BD2CF65FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } +} diff --git a/src/Bundle/ChillMainBundle/migrations/Version20211229140308.php b/src/Bundle/ChillMainBundle/migrations/Version20211229140308.php new file mode 100644 index 000000000..cfa0b4b5a --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20211229140308.php @@ -0,0 +1,41 @@ +addSql('ALTER TABLE chill_main_notification DROP CONSTRAINT FK_5BDC806765FF1AEC'); + $this->addSql('DROP INDEX IDX_5BDC806765FF1AEC'); + $this->addSql('ALTER TABLE chill_main_notification DROP updatedAt'); + $this->addSql('ALTER TABLE chill_main_notification DROP updatedBy_id'); + } + + public function getDescription(): string + { + return 'Notification: add updated tracking information'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_main_notification ADD updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('UPDATE chill_main_notification SET updatedAt="date"'); + $this->addSql('ALTER TABLE chill_main_notification ADD updatedBy_id INT DEFAULT NULL'); + $this->addSql('COMMENT ON COLUMN chill_main_notification.updatedAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_main_notification ADD CONSTRAINT FK_5BDC806765FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_5BDC806765FF1AEC ON chill_main_notification (updatedBy_id)'); + } +} diff --git a/src/Bundle/ChillMainBundle/migrations/Version20211230003532.php b/src/Bundle/ChillMainBundle/migrations/Version20211230003532.php new file mode 100644 index 000000000..20d75749c --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20211230003532.php @@ -0,0 +1,38 @@ +addSql('ALTER TABLE chill_main_notification DROP title'); + $this->addSql('ALTER TABLE chill_main_notification ALTER sender_id SET NOT NULL'); + $this->addSql('ALTER TABLE chill_main_notification ALTER updatedAt DROP NOT NULL'); + } + + public function getDescription(): string + { + return 'Add title and allow system notification (sender is null)'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_main_notification ADD title TEXT DEFAULT \'\' NOT NULL'); + $this->addSql('ALTER TABLE chill_main_notification ALTER sender_id DROP NOT NULL'); + $this->addSql('UPDATE chill_main_notification set updatedat="date"'); + $this->addSql('ALTER TABLE chill_main_notification ALTER updatedat SET NOT NULL'); + } +} diff --git a/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml index d5dedbb9f..4f25b46da 100644 --- a/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml +++ b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml @@ -1,6 +1,15 @@ years_old: >- - {age, plural, + {age, plural, one {# an} many {# ans} other {# ans} } + +notification: + My notifications with counter: >- + {nb, plural, + =0 {Mes notifications} + one {Une notification} + few {# notifications} + other {# notifications} + } diff --git a/src/Bundle/ChillMainBundle/translations/messages.fr.yml b/src/Bundle/ChillMainBundle/translations/messages.fr.yml index 4ae23f1e3..a3a375891 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.fr.yml @@ -351,3 +351,33 @@ By: Par For: Pour Created for: Créé pour Created by: Créé par + +notification: + Notification: Notification + My own notifications: Mes notifications + Notify: Envoyer une notification + Send: Envoyer + Edit notification: Modifier une notification + Notification created: Notification envoyée + Notification updated: La notification a été mise à jour + Any notification received: Aucune notification reçue + Any notification sent: Aucune notification envoyée + Notifications received: Notifications reçues + Notifications sent: Notifications envoyées + comment_appended: Commentaire ajouté + append_comment: Ajouter un commentaire + comment_updated: Commentaire mis à jour + comments_list: Fil de commentaires + show notification from %sender%: Voir la notification de %sender% + is_unread: Non-lue + is_system: notification automatique + list: Notifications + Sent: Envoyé + to: À + sent_to: Destinataire(s) + from: De + received_from: Expéditeur + you were notified by %sender%: Vous avez été notifié par %sender% + you were notified by system: Vous avez été notifié automatiquement + subject: Objet + diff --git a/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Workflow/WorkflowEventSubscriber.php b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Workflow/WorkflowEventSubscriber.php new file mode 100644 index 000000000..3b7626903 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Workflow/WorkflowEventSubscriber.php @@ -0,0 +1,77 @@ +security = $security; + $this->translator = $translator; + $this->engine = $engine; + $this->em = $em; + } + + public static function getSubscribedEvents() + { + return [ + 'workflow.accompanying_period_lifecycle.entered' => [ + 'onStateEntered', + ], + ]; + } + + public function onStateEntered(EnteredEvent $enteredEvent): void + { + if ($enteredEvent->getMarking()->has(AccompanyingPeriod::STEP_CONFIRMED)) { + $this->onPeriodConfirmed($enteredEvent->getSubject()); + } + } + + private function onPeriodConfirmed(AccompanyingPeriod $period) + { + if ($period->getUser() instanceof User + && $period->getUser() !== $this->security->getUser()) { + $notification = new Notification(); + $notification + ->setRelatedEntityId($period->getId()) + ->setRelatedEntityClass(AccompanyingPeriod::class) + ->setTitle($this->translator->trans('period_notification.period_designated_subject')) + ->setMessage($this->engine->render( + '@ChillPerson/Notification/accompanying_course_designation.md.twig', + [ + 'accompanyingCourse' => $period, + ] + )) + ->addAddressee($period->getUser()); + $this->em->persist($notification); + } + } +} diff --git a/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadHouseholdPosition.php b/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadHouseholdPosition.php index 1642a71b9..fed7d2817 100644 --- a/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadHouseholdPosition.php +++ b/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadHouseholdPosition.php @@ -33,7 +33,7 @@ class LoadHouseholdPosition extends Fixture { foreach ( self::POSITIONS_DATA as [$name, $share, $allowHolder, - $ordering, $ref, ] + $ordering, $ref, ] ) { $position = (new Position()) ->setLabel(['fr' => $name]) diff --git a/src/Bundle/ChillPersonBundle/Notification/AccompanyingPeriodNotificationHandler.php b/src/Bundle/ChillPersonBundle/Notification/AccompanyingPeriodNotificationHandler.php new file mode 100644 index 000000000..384daf07b --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Notification/AccompanyingPeriodNotificationHandler.php @@ -0,0 +1,45 @@ +accompanyingPeriodRepository = $accompanyingPeriodRepository; + } + + public function getTemplate(Notification $notification, array $options = []): string + { + return 'ChillPersonBundle:AccompanyingPeriod:showInNotification.html.twig'; + } + + public function getTemplateData(Notification $notification, array $options = []): array + { + return [ + 'notification' => $notification, + 'period' => $this->accompanyingPeriodRepository->find($notification->getRelatedEntityId()), + ]; + } + + public function supports(Notification $notification, array $options = []): bool + { + return $notification->getRelatedEntityClass() === AccompanyingPeriod::class; + } +} diff --git a/src/Bundle/ChillPersonBundle/Notification/AccompanyingPeriodNotificationRenderer.php b/src/Bundle/ChillPersonBundle/Notification/AccompanyingPeriodNotificationRenderer.php deleted file mode 100644 index c67c3d8a8..000000000 --- a/src/Bundle/ChillPersonBundle/Notification/AccompanyingPeriodNotificationRenderer.php +++ /dev/null @@ -1,33 +0,0 @@ - $notification]; - } - - public function supports(Notification $notification) - { - return $notification->getRelatedEntityClass() === AccompanyingPeriod::class; - } -} diff --git a/src/Bundle/ChillPersonBundle/Resources/public/chill/chillperson.scss b/src/Bundle/ChillPersonBundle/Resources/public/chill/chillperson.scss index db5df905a..30eadc7b1 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/chill/chillperson.scss +++ b/src/Bundle/ChillPersonBundle/Resources/public/chill/chillperson.scss @@ -259,9 +259,9 @@ abbr.referrer { // still used ? div#dashboards { div.mbloc { & > div:not(.warnings) { - border: 1px solid $chill-light-gray; + //border: 1px solid $chill-light-gray; + //border-radius: 0.35rem; background-color: $chill-llight-gray; - border-radius: 0.35rem; padding: 1rem; } & > div.warnings .alert { diff --git a/src/Bundle/ChillPersonBundle/Resources/public/chill/scss/badge.scss b/src/Bundle/ChillPersonBundle/Resources/public/chill/scss/badge.scss index b0147ebdf..46c13d152 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/chill/scss/badge.scss +++ b/src/Bundle/ChillPersonBundle/Resources/public/chill/scss/badge.scss @@ -20,6 +20,11 @@ span.badge-thirdparty { span.badge-user { border-bottom-width: 1px; + &.system { + background-color: $chill-llight-gray; + font-style: italic; + color: $chill-gray; + } } span.badge-person { border-bottom-color: $chill-green; diff --git a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourse/Comment/index.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourse/Comment/index.html.twig index 35d3eb632..cbebe5364 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourse/Comment/index.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourse/Comment/index.html.twig @@ -81,6 +81,7 @@ {% endif %} {% endfor %}
    + {% if form is not null %}

    {{ 'Write a new comment'|trans }}

    diff --git a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourse/index.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourse/index.html.twig index e5ebaea95..db4042b1b 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourse/index.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourse/index.html.twig @@ -13,14 +13,19 @@ {% endmacro %} {% block js %} -{{ parent() }} -{{ encore_entry_script_tags('page_accompanying_course_index_person_locate') }} -{{ encore_entry_script_tags('page_accompanying_course_index_masonry') }} + {{ parent() }} + {{ encore_entry_script_tags('page_accompanying_course_index_person_locate') }} + {{ encore_entry_script_tags('page_accompanying_course_index_masonry') }} + {{ encore_entry_script_tags('mod_notification_toggle_read_status') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('mod_notification_toggle_read_status') }} {% endblock %} {% block content %}
    -
    {% if 'DRAFT' == accompanyingCourse.step %} @@ -186,3 +191,18 @@
    {% endblock %} + +{% block block_post_menu %} +
    + + + + {{ chill_list_notifications('Chill\\PersonBundle\\Entity\\AccompanyingPeriod', accompanyingCourse.id) }} + +
    +{% endblock %} diff --git a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingPeriod/_list.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingPeriod/_list.html.twig index 48a3f1364..307c462e2 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingPeriod/_list.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingPeriod/_list.html.twig @@ -1,170 +1,60 @@ +{% if person is defined %} + {% set contextEntity = { 'type': 'person', 'entity': person } %}{% endif %} +{% if household is defined %} + {% set contextEntity = { 'type': 'household', 'entity': household } %}{% endif %} + +{% macro recordAction(period, contextEntity) %} + {# TODO if enable_accompanying_course_with_multiple_persons is true ... #} +
  • + {# {{ 'See this period'|trans }} #} +
  • + {% if period.step == 'DRAFT' and contextEntity.type == 'person' %} + {% set person = contextEntity.entity %} +
  • + {# {{ 'Delete this period'|trans }} #} +
  • + {% endif %} + + {# DISABLED if new accompanying course, this is not necessary + {% if person is defined %} +
  • + +
  • + {% if period.isOpen == true %} +
  • + + + {{'Close accompanying period'|trans }} + +
  • + {% endif %} + {% if period.canBeReOpened(person) == true %} +
  • + + + {{'Re-open accompanying period'|trans }} + +
  • + {% endif %} + {% elseif household is defined %} + TODO buttons specific for household ? + {% endif %} + #} +{% endmacro %} + {% block content %}
    - {% for accompanying_period in accompanying_periods %} -
    -
    -
    -
    -
    - - - {{ accompanying_period.id }} - - {% if accompanying_period.emergency %} - {{- 'Emergency'|trans|upper -}} - {% endif %} - {% if accompanying_period.confidential %} - {{- 'Confidential'|trans|upper -}} - {% endif %} -
    -
    - {% if accompanying_period.step == 'DRAFT' %} - {{- 'Draft'|trans|upper -}} - {% elseif accompanying_period.step == 'CONFIRMED' %} - {{- 'Confirmed'|trans|upper -}} - {% else %} - {{- 'Closed'|trans|upper -}} - {% endif %} -
    -
    -
    -
    - {% if accompanying_period.closingDate == null %} - {{ 'accompanying_period.dates_from_%opening_date%'|trans({ '%opening_date%': accompanying_period.openingDate|format_date('long') } ) }} - {% else %} - {{ 'accompanying_period.dates_from_%opening_date%_to_%closing_date%'|trans({ - '%opening_date%': accompanying_period.openingDate|format_date('long'), - '%closing_date%': accompanying_period.closingDate|format_date('long')} - ) }} - {% if accompanying_period.isOpen == false %} -
    -
    {{ 'Closing motive'|trans }} :
    -
    {{ accompanying_period.closingMotive|chill_entity_render_box }}
    -
    - {% endif %} - {% endif %} -
    -
    - {% if chill_accompanying_periods.fields.user == 'visible' %} - {% if accompanying_period.user %} - ref: - {{ accompanying_period.user.username|chill_entity_render_box }} - {% else %} - {{ 'No accompanying user'|trans }} - {% endif %} - {% endif %} -
    -
    -
    + {% for period in accompanying_periods %} -
    -
    -
    - {% if accompanying_period.requestorPerson is not null or accompanying_period.requestorThirdParty is not null %} -
    -

    {{ 'Requestor'|trans({'gender': null }) }}

    -
    - {% if accompanying_period.requestorPerson is not null %} - - {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { - action: 'show', displayBadge: true, - targetEntity: { name: 'person', id: accompanying_period.requestorPerson.id }, - buttonText: accompanying_period.requestorPerson|chill_entity_render_string - } %} - - {% endif %} - {% if accompanying_period.requestorThirdParty is not null %} - - {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { - action: 'show', displayBadge: true, - targetEntity: { name: 'thirdparty', id: accompanying_period.requestorThirdParty.id }, - buttonText: accompanying_period.requestorThirdParty|chill_entity_render_string - } %} - - {% endif %} -
    -
    - {% endif %} + {% include 'ChillPersonBundle:AccompanyingPeriod:_list_item.html.twig' with { + 'recordAction': _self.recordAction(period, contextEntity) + } %} - {% if accompanying_period.participations.count > 0 %} -
    -

    {{ 'Participants'|trans }}

    -
    - {% for p in accompanying_period.getCurrentParticipations %} - - {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { - action: 'show', displayBadge: true, - targetEntity: { name: 'person', id: p.person.id }, - buttonText: p.person|chill_entity_render_string - } %} - - {% endfor %} -
    -
    - {% endif %} - - {% if accompanying_period.socialIssues.count > 0 %} -
    -

    {{ 'Social issues'|trans }}

    -
    - {% for si in accompanying_period.socialIssues %} -

    - {{ si|chill_entity_render_box }} -

    - {% endfor %} -
    -
    - {% endif %} -
    - -
    -
    - - - -
    -
    {% endfor %}
    {% endblock content %} diff --git a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingPeriod/_list_item.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingPeriod/_list_item.html.twig new file mode 100644 index 000000000..79e159596 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingPeriod/_list_item.html.twig @@ -0,0 +1,121 @@ +
    +
    +
    +
    +
    + + + {{ period.id }} + + {% if period.emergency %} + {{- 'Emergency'|trans|upper -}} + {% endif %} + {% if period.confidential %} + {{- 'Confidential'|trans|upper -}} + {% endif %} +
    +
    + {% if period.step == 'DRAFT' %} + {{- 'Draft'|trans|upper -}} + {% elseif period.step == 'CONFIRMED' %} + {{- 'Confirmed'|trans|upper -}} + {% else %} + {{- 'Closed'|trans|upper -}} + {% endif %} +
    +
    +
    +
    + {% if period.closingDate == null %} + {{ 'accompanying_period.dates_from_%opening_date%'|trans({ '%opening_date%': period.openingDate|format_date('long') } ) }} + {% else %} + {{ 'accompanying_period.dates_from_%opening_date%_to_%closing_date%'|trans({ + '%opening_date%': period.openingDate|format_date('long'), + '%closing_date%': period.closingDate|format_date('long')} + ) }} + {% if period.isOpen == false %} +
    +
    {{ 'Closing motive'|trans }} :
    +
    {{ period.closingMotive|chill_entity_render_box }}
    +
    + {% endif %} + {% endif %} +
    +
    + {% if chill_accompanying_periods.fields.user == 'visible' %} + {% if period.user %} + {{ 'Referrer'|trans }}: + {{ period.user.username|chill_entity_render_box }} + {% else %} + {{ 'No accompanying user'|trans }} + {% endif %} + {% endif %} +
    +
    +
    +
    +
    +
    + {% if period.requestorPerson is not null or period.requestorThirdParty is not null %} +
    +

    {{ 'Requestor'|trans({'gender': null }) }}

    +
    + {% if period.requestorPerson is not null %} + + {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { + action: 'show', displayBadge: true, + targetEntity: { name: 'person', id: period.requestorPerson.id }, + buttonText: period.requestorPerson|chill_entity_render_string + } %} + + {% endif %} + {% if period.requestorThirdParty is not null %} + + {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { + action: 'show', displayBadge: true, + targetEntity: { name: 'thirdparty', id: period.requestorThirdParty.id }, + buttonText: period.requestorThirdParty|chill_entity_render_string + } %} + + {% endif %} +
    +
    + {% endif %} + {% if period.participations.count > 0 %} +
    +

    {{ 'Participants'|trans }}

    +
    + {% for p in period.getCurrentParticipations %} + + {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { + action: 'show', displayBadge: true, + targetEntity: { name: 'person', id: p.person.id }, + buttonText: p.person|chill_entity_render_string + } %} + + {% endfor %} +
    +
    + {% endif %} + {% if period.socialIssues.count > 0 %} +
    +

    {{ 'Social issues'|trans }}

    +
    + {% for si in period.socialIssues %} +

    + {{ si|chill_entity_render_box }} +

    + {% endfor %} +
    +
    + {% endif %} +
    +
    + {% if recordAction is defined %} +
    +
      + {{ recordAction }} +
    +
    + {% endif %} +
    diff --git a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingPeriod/showInNotification.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingPeriod/showInNotification.html.twig index d92de8cba..630dcf100 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingPeriod/showInNotification.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingPeriod/showInNotification.html.twig @@ -1,3 +1,26 @@ - - Go to Acc. period. - +{% macro recordAction(period) %} +
  • + +
  • +{% endmacro %} + +{% if period is not null %} +
    + {% if is_granted('CHILL_PERSON_ACCOMPANYING_PERIOD_SEE', period) %} + {% include 'ChillPersonBundle:AccompanyingPeriod:_list_item.html.twig' with { + 'recordAction': _self.recordAction(notification.relatedEntityId), + 'itemBlocClass': 'bg-chill-llight-gray' + } %} + {% else %} +
    + {{ 'This is the minimal period details'|trans ~ ': ' ~ period.id }}
    + {{ 'You are getting a notification for a period you are not allowed to see'|trans }} +
    + {% endif %} +
    +{% else %} +
    + {{ 'You are getting a notification for a period which does not exists any more'|trans }} +
    +{% endif %} diff --git a/src/Bundle/ChillPersonBundle/Resources/views/Household/summary.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/Household/summary.html.twig index df1e45fce..bbb8ff52f 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/Household/summary.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/Household/summary.html.twig @@ -3,7 +3,7 @@ {% block title 'household.Household summary'|trans %} {% block block_post_menu %} -
    +
    {% endblock %} {% block content %} diff --git a/src/Bundle/ChillPersonBundle/Resources/views/Notification/accompanying_course_designation.md.twig b/src/Bundle/ChillPersonBundle/Resources/views/Notification/accompanying_course_designation.md.twig new file mode 100644 index 000000000..879df495e --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/views/Notification/accompanying_course_designation.md.twig @@ -0,0 +1,14 @@ +{{ 'period_notification.You are designated to a new period'|trans }} + +{{ 'period_notification.See it online'|trans }}: + +{{ absolute_url(path('chill_person_accompanying_course_index', {'accompanying_period_id': accompanyingCourse.id}, false)) }} + +{{ 'period_notification.Persons are'|trans }}: + +{% for p in accompanyingCourse.getCurrentParticipations %} +* {{ p.person|chill_entity_render_string }} +{% endfor %} + +{{ 'period_notification.Social issues are'|trans }}: {% for s in accompanyingCourse.socialIssues %}{{ s|chill_entity_render_string }}{% if not loop.last %}, {% endif %}{% endfor %}. + diff --git a/src/Bundle/ChillPersonBundle/Resources/views/Person/layout.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/Person/layout.html.twig index 350b73679..f04f9d702 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/Person/layout.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/Person/layout.html.twig @@ -1,5 +1,5 @@ {# - * Copyright (C) 2014-2021, Champs Libres Cooperative SCRLFS, + * Copyright (C) 2014-2021, Champs Libres Cooperative SCRLFS, / * * This program is free software: you can redistribute it and/or modify @@ -38,7 +38,7 @@ }) }} {% block block_post_menu %} -
    +
    {{ chill_delegated_block('person_post_vertical_menu', { 'person': person } ) }}
    {% endblock %} diff --git a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/AccompanyingPeriodDocGenNormalizer.php b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/AccompanyingPeriodDocGenNormalizer.php index bb6f273c7..ceaba6ed8 100644 --- a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/AccompanyingPeriodDocGenNormalizer.php +++ b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/AccompanyingPeriodDocGenNormalizer.php @@ -98,7 +98,7 @@ class AccompanyingPeriodDocGenNormalizer implements ContextAwareNormalizerInterf /** * @param AccompanyingPeriod|null $period - * @param null|string $format + * @param string|null $format */ public function normalize($period, $format = null, array $context = []) { diff --git a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/AccompanyingPeriodOriginNormalizer.php b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/AccompanyingPeriodOriginNormalizer.php index 88dce414f..66f65b67b 100644 --- a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/AccompanyingPeriodOriginNormalizer.php +++ b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/AccompanyingPeriodOriginNormalizer.php @@ -21,7 +21,7 @@ final class AccompanyingPeriodOriginNormalizer implements NormalizerInterface { /** * @param Origin $origin - * @param null|string $format + * @param string|null $format */ public function normalize($origin, $format = null, array $context = []) { diff --git a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/AccompanyingPeriodParticipationNormalizer.php b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/AccompanyingPeriodParticipationNormalizer.php index b392ccc24..d9472184e 100644 --- a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/AccompanyingPeriodParticipationNormalizer.php +++ b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/AccompanyingPeriodParticipationNormalizer.php @@ -21,7 +21,7 @@ class AccompanyingPeriodParticipationNormalizer implements NormalizerAwareInterf /** * @param AccompanyingPeriodParticipation $participation - * @param null|string $format + * @param string|null $format */ public function normalize($participation, $format = null, array $context = []) { diff --git a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonNormalizer.php b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonNormalizer.php index 8d23f8974..d6f9ced53 100644 --- a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonNormalizer.php +++ b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonNormalizer.php @@ -168,7 +168,7 @@ class PersonJsonNormalizer implements /** * @param Person $person - * @param null|string $format + * @param string|null $format */ public function normalize($person, $format = null, array $context = []) { diff --git a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/RelationshipDocGenNormalizer.php b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/RelationshipDocGenNormalizer.php index cfae9f6e6..52a0f701c 100644 --- a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/RelationshipDocGenNormalizer.php +++ b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/RelationshipDocGenNormalizer.php @@ -31,7 +31,7 @@ class RelationshipDocGenNormalizer implements ContextAwareNormalizerInterface, N /** * @param Relationship $relation - * @param null|string $format + * @param string|null $format */ public function normalize($relation, $format = null, array $context = []) { diff --git a/src/Bundle/ChillPersonBundle/Tests/AccompanyingPeriod/Workflow/WorkflowEventSubscriberTest.php b/src/Bundle/ChillPersonBundle/Tests/AccompanyingPeriod/Workflow/WorkflowEventSubscriberTest.php new file mode 100644 index 000000000..8867872d3 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/AccompanyingPeriod/Workflow/WorkflowEventSubscriberTest.php @@ -0,0 +1,22 @@ +