diff --git a/invenio_communities/members/services/request.py b/invenio_communities/members/services/request.py index 4ed4d7e59..9c2b1b527 100644 --- a/invenio_communities/members/services/request.py +++ b/invenio_communities/members/services/request.py @@ -19,6 +19,10 @@ CommunityInvitationCancelNotificationBuilder, CommunityInvitationDeclineNotificationBuilder, CommunityInvitationExpireNotificationBuilder, + CommunityMembershipRequestAcceptNotificationBuilder, + CommunityMembershipRequestCancelNotificationBuilder, + CommunityMembershipRequestDeclineNotificationBuilder, + CommunityMembershipRequestExpireNotificationBuilder, ) from ...proxies import current_communities @@ -142,7 +146,11 @@ class CancelMembershipRequestAction(actions.CancelAction): def execute(self, identity, uow): """Execute action.""" service().close_member_request(system_identity, self.request.id, uow=uow) - # TODO: Notification flow: Investigate notifications + uow.register( + NotificationOp( + CommunityMembershipRequestCancelNotificationBuilder.build(self.request) + ) + ) super().execute(identity, uow) @@ -152,7 +160,11 @@ class DeclineMembershipRequestAction(actions.DeclineAction): def execute(self, identity, uow): """Execute action.""" service().close_member_request(system_identity, self.request.id, uow=uow) - # TODO: Notification flow: Investigate notifications + uow.register( + NotificationOp( + CommunityMembershipRequestDeclineNotificationBuilder.build(self.request) + ) + ) super().execute(identity, uow) @@ -165,7 +177,11 @@ class ExpireMembershipRequestAction(actions.ExpireAction): def execute(self, identity, uow): """Execute action.""" service().close_member_request(system_identity, self.request.id, uow=uow) - # TODO: Notification flow: Investigate notifications + uow.register( + NotificationOp( + CommunityMembershipRequestExpireNotificationBuilder.build(self.request) + ) + ) super().execute(identity, uow) @@ -175,7 +191,11 @@ class AcceptMembershipRequestAction(actions.AcceptAction): def execute(self, identity, uow): """Execute action.""" service().accept_member_request(system_identity, self.request.id, uow=uow) - # TODO: Notification flow: Investigate notifications + uow.register( + NotificationOp( + CommunityMembershipRequestAcceptNotificationBuilder.build(self.request) + ) + ) super().execute(identity, uow) diff --git a/invenio_communities/members/services/service.py b/invenio_communities/members/services/service.py index a53967b85..259091449 100644 --- a/invenio_communities/members/services/service.py +++ b/invenio_communities/members/services/service.py @@ -35,7 +35,10 @@ from sqlalchemy.exc import IntegrityError from werkzeug.local import LocalProxy -from ...notifications.builders import CommunityInvitationSubmittedNotificationBuilder +from ...notifications.builders import ( + CommunityInvitationSubmittedNotificationBuilder, + CommunityMembershipRequestSubmittedNotificationBuilder, +) from ...proxies import current_roles from ..errors import AlreadyMemberError, InvalidMemberError from ..records.api import ArchivedInvitation @@ -839,22 +842,23 @@ def request_membership(self, identity, community_id, data, uow=None): ) # TODO: Notification flow: Add notification mechanism - # uow.register( - # NotificationOp( - # MembershipRequestSubmittedNotificationBuilder.build( - # request=request_item._request, - # # explicit string conversion to get the value of LazyText - # role=str(role.title), - # message=message, - # ) - # ) - # ) + role = current_roles["reader"] + uow.register( + NotificationOp( + CommunityMembershipRequestSubmittedNotificationBuilder.build( + request=request_item._record, + # explicit string conversion to get the value of LazyText + role=str(role.title), + message=message, + ) + ) + ) # Create an inactive member entry linked to the request. self._add_factory( identity, community=community, - role=current_roles["reader"], + role=role, visible=False, member={"type": "user", "id": str(identity.user.id)}, message=message, diff --git a/invenio_communities/notifications/builders.py b/invenio_communities/notifications/builders.py index 39a40672e..fc053f771 100644 --- a/invenio_communities/notifications/builders.py +++ b/invenio_communities/notifications/builders.py @@ -216,3 +216,89 @@ class SubCommunityDecline(SubCommunityBuilderBase): """Notification builder for subcommunity request decline.""" type = f"{SubCommunityBuilderBase.type}.decline" + + +class MembershipRequestBaseNotificationBuilder(BaseNotificationBuilder): + """Base membership request notification builder.""" + type = "community-membership-request" + + @classmethod + def build(cls, request, message=None): + """Build notification with request context.""" + return Notification( + type=cls.type, + context={ + "request": EntityResolverRegistry.reference_entity(request), + }, + ) + + +class CommunityMembershipRequestSubmittedNotificationBuilder(MembershipRequestBaseNotificationBuilder): + """Notification builder for community membership request submission.""" + + # identifier + type = f"{MembershipRequestBaseNotificationBuilder.type}.submit" + recipients = [ + CommunityMembersRecipient(key="request.receiver", roles=["owner", "manager"]), + ] + + @classmethod + def build(cls, request, role, message=None): + """Build notification with request context.""" + return Notification( + type=cls.type, + context={ + "request": EntityResolverRegistry.reference_entity(request), + "role": role, + "message": message, + }, + ) + + +class CommunityMembershipRequestCancelNotificationBuilder( + MembershipRequestBaseNotificationBuilder +): + """Notification builder for community membership request cancel action.""" + + # identifier + type = f"{MembershipRequestBaseNotificationBuilder.type}.cancel" + recipients = [ + CommunityMembersRecipient(key="request.receiver", roles=["owner", "manager"]), + ] + + +class CommunityMembershipRequestDeclineNotificationBuilder( + MembershipRequestBaseNotificationBuilder +): + """Notification builder for community membership request decline action.""" + + # identifier + type = f"{MembershipRequestBaseNotificationBuilder.type}.decline" + recipients = [ + UserRecipient(key="request.created_by"), + ] + + +class CommunityMembershipRequestExpireNotificationBuilder( + MembershipRequestBaseNotificationBuilder +): + """Notification builder for community membership request expire action.""" + + # identifier + type = f"{MembershipRequestBaseNotificationBuilder.type}.expire" + recipients = [ + CommunityMembersRecipient(key="request.receiver", roles=["owner", "manager"]), + UserRecipient(key="request.created_by"), + ] + + +class CommunityMembershipRequestAcceptNotificationBuilder( + MembershipRequestBaseNotificationBuilder +): + """Notification builder for community membership request accept action.""" + + # identifier + type = f"{MembershipRequestBaseNotificationBuilder.type}.accept" + recipients = [ + UserRecipient(key="request.created_by"), + ] diff --git a/invenio_communities/templates/semantic-ui/invenio_notifications/community-membership-request.accept.jinja b/invenio_communities/templates/semantic-ui/invenio_notifications/community-membership-request.accept.jinja new file mode 100644 index 000000000..1b0544f08 --- /dev/null +++ b/invenio_communities/templates/semantic-ui/invenio_notifications/community-membership-request.accept.jinja @@ -0,0 +1,57 @@ +{% set request = notification.context.request %} +{% set community = request.receiver %} +{% set created_by = request.created_by %} +{% set request_id = request.id %} +{# TODO: Action-based notifications don't pass `message` so this will always be empty #} +{% set message = notification.context.message | safe if notification.context.message else '' %} +{% set community_title = community.metadata.title %} +{# This email is sent to the requester only so omitted requester's name #} + +{# TODO: use request.links.self_html when this issue is resolved: https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 #} +{% set request_link = "{ui}/me/requests/{id}".format(ui=config.SITE_UI_URL, id=request_id) %} +{# "/account/settings/notifications" is hardcoded in invenio-notifications +and not publicly exposed so ok to refer to it directly for now #} +{% set account_settings_link = "{ui}/account/settings/notifications".format(ui=config.SITE_UI_URL) %} + +{%- block subject -%} +{{ _("✅ Request to join the community '{community_title}' was accepted").format(community_title=community_title) }} +{%- endblock subject -%} + +{%- block html_body -%} +
{{ _("The membership request to join the community '{community_title}' was accepted").format(community_title=community_title) }} + {% if message %}{{ _(" with the following message:")}}{% endif %} + | +
"{{message}}" | + {% endif %} +
{{ _("Check out the membership request")}} | +
_ | +
{{ _("This is an auto-generated message. To manage notifications, visit your")}} {{ _("account settings")}}. | +
{{ _("The membership request for '@{requester_name}' to join the community '{community_title}' was cancelled").format(requester_name=requester_name, community_title=community_title) }} + {% if message %}{{ _(" with the following message:")}}{% endif %} + | +
"{{message}}" | + {% endif %} +
{{ _("Check out the membership request")}} | +
_ | +
{{ _("This is an auto-generated message. To manage notifications, visit your")}} {{ _("account settings")}}. | +
{{ _("The membership request to join the community '{community_title}' was declined").format(community_title=community_title) }} + {% if message %}{{ _(" with the following message:")}}{% endif %} + | +
"{{message}}" | + {% endif %} +
{{ _("Check out the membership request")}} | +
_ | +
{{ _("This is an auto-generated message. To manage notifications, visit your")}} {{ _("account settings")}}. | +
{{ _("The membership request for '@{requester_name}' to join the community '{community_title}' expired.").format(requester_name=requester_name, community_title=community_title) }} | +
{{ _("Check out the membership request")}} | +
_ | +
{{ _("This is an auto-generated message. To manage notifications, visit your")}} {{ _("account settings")}}. | +
{{ _("'@{user_name}' wants to join the community '{community_title}' as '{role}'").format(user_name=user_name, community_title=community_title, role=role) }} + {% if message %}{{ _(" with the following message:")}}{% endif %} + | +
"{{message}}" | + {% endif %} +
{{ _("Check out the membership request")}} | +
_ | +
{{ _("This is an auto-generated message. To manage notifications, visit your")}} {{ _("account settings")}}. | +
membership request message
" + data = { + "message": message, + } + request_result = member_service.request_membership( + new_user.identity, community.id, data + ) + + assert mock_build.called + assert 2 == len(outbox) + all_send_to = reduce(lambda s, m: m.send_to | s, outbox, set()) + assert {"manager@manager.org", "owner@owner.org"} == all_send_to + # Same content across all messages so just testing first + html = outbox[0].html + # Since receivers of the request are community owners + managers + # the link in the request is to the community request page + request_id = request_result.id + assert f"/communities/{community.id}/requests/{request_id}" in html + who = new_user.user.username or new_user.user.user_profile.get("full_name") + title = community["metadata"]["title"] + role = "Reader" + assert f"'@{who}' wants to join the community '{title}' as '{role}'" in html + assert message in html + + +def test_request_membership_cancel_notification( + setup_mock_of_notification_builder, + member_service, + requests_service, + community, + members, # to make sure manager exists + create_user, + db, + app, + clean_index, +): + mock_build = setup_mock_of_notification_builder( + CommunityMembershipRequestCancelNotificationBuilder + ) + mail = app.extensions.get("mail") + assert mail + new_user = create_user() + message = "membership request message
" + data = { + "message": message, + } + request_result = member_service.request_membership( + new_user.identity, community.id, data + ) + + # Validate that email was sent + with mail.record_messages() as outbox: + requests_service.execute_action(new_user.identity, request_result.id, "cancel") + + assert mock_build.called + assert 2 == len(outbox) + all_send_to = reduce(lambda s, m: m.send_to | s, outbox, set()) + assert {"manager@manager.org", "owner@owner.org"} == all_send_to + html = outbox[0].html + # Since receivers of the request are community owners + managers + # the link in the request is to the community request page + request_id = request_result.id + assert f"/communities/{community.id}/requests/{request_id}" in html + who = new_user.user.username or new_user.user.user_profile.get("full_name") + title = community["metadata"]["title"] + assert ( + f"The membership request for '@{who}' to join the community '{title}' was cancelled" + in html + ) + + +def test_request_membership_decline_notification( + setup_mock_of_notification_builder, + member_service, + requests_service, + community, + owner, + members, + create_user, + db, + app, + clean_index, +): + mock_build = setup_mock_of_notification_builder( + CommunityMembershipRequestDeclineNotificationBuilder + ) + mail = app.extensions.get("mail") + assert mail + new_user = create_user() + message = "membership request message
" + data = { + "message": message, + } + request_result = member_service.request_membership( + new_user.identity, community.id, data + ) + + # Validate that email was sent + with mail.record_messages() as outbox: + requests_service.execute_action(owner.identity, request_result.id, "decline") + + assert mock_build.called + assert 1 == len(outbox) + assert {"user@example.org"} == outbox[0].send_to + html = outbox[0].html + # Since receivers of the request is the requester community owners + managers + # the link in the request is to the community request page + request_id = request_result.id + assert f"/me/requests/{request_id}" in html + title = community["metadata"]["title"] + assert ( + f"The membership request to join the community '{title}' was declined" + in html + ) + + +def test_request_membership_expire_notification( + setup_mock_of_notification_builder, + member_service, + requests_service, + community, + members, + create_user, + db, + app, + clean_index, +): + mock_build = setup_mock_of_notification_builder( + CommunityMembershipRequestExpireNotificationBuilder + ) + assert not mock_build.called + mail = app.extensions.get("mail") + assert mail + new_user = create_user() + message = "membership request message
" + data = { + "message": message, + } + request_result = member_service.request_membership( + new_user.identity, community.id, data + ) + + # Validate that email was sent + with mail.record_messages() as outbox: + requests_service.execute_action(system_identity, request_result.id, "expire") + + assert mock_build.called + assert 3 == len(outbox) + # owners, managers and requester should all be notified + all_send_to = reduce(lambda s, m: m.send_to | s, outbox, set()) + # fmt: off + assert ( + {"owner@owner.org" , "manager@manager.org", "user@example.org"} == all_send_to + ) + # fmt: on + html = outbox[0].html + # TODO: Can we make it depend on receiver? + # Since receivers of the request are community owners + managers + requester + # the link in the request is to the community request page + request_id = request_result.id + assert f"/communities/{community.id}/requests/{request_id}" in html + who = new_user.user.username or new_user.user.user_profile.get("full_name") + title = community["metadata"]["title"] + assert ( + f"The membership request for '@{who}' to join the community '{title}' expired" + in html + ) + + +def test_request_membership_accept_notification( + setup_mock_of_notification_builder, + member_service, + requests_service, + community, + owner, + members, + create_user, + db, + app, + clean_index, +): + mock_build = setup_mock_of_notification_builder( + CommunityMembershipRequestAcceptNotificationBuilder + ) + assert not mock_build.called + mail = app.extensions.get("mail") + assert mail + new_user = create_user() + message = "membership request message
" + data = { + "message": message, + } + request_result = member_service.request_membership( + new_user.identity, community.id, data + ) + + # Validate that email was sent + with mail.record_messages() as outbox: + requests_service.execute_action(owner.identity, request_result.id, "accept") + + assert mock_build.called + assert 1 == len(outbox) + # requester should be notified + assert {"user@example.org"} == outbox[0].send_to + html = outbox[0].html + request_id = request_result.id + assert f"/me/requests/{request_id}" in html + title = community["metadata"]["title"] + assert ( + f"The membership request to join the community '{title}' was accepted" + in html + )