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 -%} + + + + + + {% if message %} + + {% endif %} + + + + + + + + + + +
{{ _("The membership request to join the community '{community_title}' was accepted").format(community_title=community_title) }} + {% if message %}{{ _(" with the following message:")}}{% endif %} +
"{{message}}"
{{ _("Check out the membership request")}}
_
{{ _("This is an auto-generated message. To manage notifications, visit your")}} {{ _("account settings")}}.
+{%- endblock html_body %} + +{%- block plain_body -%} +{{ _("The membership request to join the community '{community_title}' was accepted").format(community_title=community_title) }} +{% if message %}{{ _("with the following message:")}} {{message}}{% endif %} + +{{ _("Check out the membership request:") }} {{ request_link }} +{%- endblock plain_body %} + +{# Markdown for Slack/Mattermost/chat #} +{%- block md_body -%} +{{ _("The membership request to join the community *{community_title}* was accepted").format(community_title=community_title) }} +{% if message %}{{ _("with the following message:")}} {{ message }}{% endif %} + +[{{ _("Check out the membership request") }}]({{ request_link }}) +{%- endblock md_body %} diff --git a/invenio_communities/templates/semantic-ui/invenio_notifications/community-membership-request.cancel.jinja b/invenio_communities/templates/semantic-ui/invenio_notifications/community-membership-request.cancel.jinja new file mode 100644 index 000000000..4ae66a0d4 --- /dev/null +++ b/invenio_communities/templates/semantic-ui/invenio_notifications/community-membership-request.cancel.jinja @@ -0,0 +1,56 @@ +{% set request = notification.context.request %} +{% set community = request.receiver %} +{% set created_by = request.created_by %} +{% set requester_name = created_by.username or created_by.profile.full_name %} +{% set request_id = request.id %} +{% set message = notification.context.message | safe if notification.context.message else '' %} +{% set community_title = community.metadata.title %} + +{# WARNING: this uses a config set by invenio-app-rdm (not a dependency) to at least not hardcode the URL #} +{# invenio-app-rdm should have been using REQUESTS_ROUTES a config of a dependency however. #} +{% set request_link_path = config.RDM_REQUESTS_ROUTES["community-dashboard-request-details"].lstrip("/") %} +{% set request_link_path = request_link_path.replace("", community.id).replace("", request_id) %} +{% set request_link = "{ui}/{request_link_path}".format(ui=config.SITE_UI_URL, request_link_path=request_link_path) %} + +{%- block subject -%} +{{ _("❌ Request for '@{requester_name}' to join the community '{community_title}' was cancelled").format(requester_name=requester_name, community_title=community_title) }} +{%- endblock subject -%} + +{%- block html_body -%} + + + + + + {% if message %} + + {% endif %} + + + + + + + + + + +
{{ _("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}}"
{{ _("Check out the membership request")}}
_
{{ _("This is an auto-generated message. To manage notifications, visit your")}} {{ _("account settings")}}.
+{%- endblock html_body %} + +{%- block plain_body -%} +{{ _("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:")}} {{ message }}{% endif %} + +{{ _("Check out the membership request:") }} {{ request_link }} +{%- endblock plain_body %} + +{# Markdown for Slack/Mattermost/chat #} +{%- block md_body -%} +{{ _("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:")}} {{message}}{% endif %} + +[{{ _("Check out the membership request") }}]({{ request_link }}) +{%- endblock md_body %} diff --git a/invenio_communities/templates/semantic-ui/invenio_notifications/community-membership-request.decline.jinja b/invenio_communities/templates/semantic-ui/invenio_notifications/community-membership-request.decline.jinja new file mode 100644 index 000000000..a627b24aa --- /dev/null +++ b/invenio_communities/templates/semantic-ui/invenio_notifications/community-membership-request.decline.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 declined").format(community_title=community_title) }} +{%- endblock subject -%} + +{%- block html_body -%} + + + + + + {% if message %} + + {% endif %} + + + + + + + + + + +
{{ _("The membership request to join the community '{community_title}' was declined").format(community_title=community_title) }} + {% if message %}{{ _(" with the following message:")}}{% endif %} +
"{{message}}"
{{ _("Check out the membership request")}}
_
{{ _("This is an auto-generated message. To manage notifications, visit your")}} {{ _("account settings")}}.
+{%- endblock html_body %} + +{%- block plain_body -%} +{{ _("The membership request to join the community '{community_title}' was declined").format(community_title=community_title) }} +{% if message %}{{ _("with the following message:")}} {{message}}{% endif %} + +{{ _("Check out the membership request:") }} {{ request_link }} +{%- endblock plain_body %} + +{# Markdown for Slack/Mattermost/chat #} +{%- block md_body -%} +{{ _("The membership request to join the community *{community_title}* was declined").format(community_title=community_title) }} +{% if message %}{{ _("with the following message:")}} {{ message }}{% endif %} + +[{{ _("Check out the membership request") }}]({{ request_link }}) +{%- endblock md_body %} diff --git a/invenio_communities/templates/semantic-ui/invenio_notifications/community-membership-request.expire.jinja b/invenio_communities/templates/semantic-ui/invenio_notifications/community-membership-request.expire.jinja new file mode 100644 index 000000000..8f9ecb69d --- /dev/null +++ b/invenio_communities/templates/semantic-ui/invenio_notifications/community-membership-request.expire.jinja @@ -0,0 +1,48 @@ +{% set request = notification.context.request %} +{% set community = request.receiver %} +{% set requester = request.created_by %} +{% set request_id = request.id %} +{% set community_title = community.metadata.title %} +{% set requester_name = requester.username or requester.profile.full_name %} + +{# TODO: Can we choose URL based on receiver? #} +{# WARNING: this uses a config set by invenio-app-rdm (not a dependency) to at least not hardcode the URL #} +{# invenio-app-rdm should have been using REQUESTS_ROUTES a config of a dependency however. #} +{% set request_link_path = config.RDM_REQUESTS_ROUTES["community-dashboard-request-details"].lstrip("/") %} +{% set request_link_path = request_link_path.replace("", community.id).replace("", request_id) %} +{% set request_link = "{ui}/{request_link_path}".format(ui=config.SITE_UI_URL, request_link_path=request_link_path) %} +{% set account_settings_link = "{ui}/account/settings/notifications".format(ui=config.SITE_UI_URL) %} + +{%- block subject -%} +{{ _("⌛️ The membership request for '@{requester_name}' to join the community '{community_title}' expired").format(requester_name=requester_name, community_title=community_title) }} +{%- endblock subject -%} + +{%- block html_body -%} + + + + + + + + + + + + + +
{{ _("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")}}.
+{%- endblock html_body %} + +{%- block plain_body -%} +{{ _("The membership request for @{requester_name} to join community '{community_title}' expired.").format(requester_name=requester_name, community_title=community_title) }} + +{{ _("Check out the membership request:") }} {{ request_link }} +{%- endblock plain_body %} + +{# Markdown for Slack/Mattermost/chat #} +{%- block md_body -%} +{{ _("The membership request for *@{requester_name}* to join community *{community_title}* expired.").format(requester_name=requester_name, community_title=community_title) }} + +[{{ _("Check out the membership request") }}]({{ request_link }}) +{%- endblock md_body %} diff --git a/invenio_communities/templates/semantic-ui/invenio_notifications/community-membership-request.submit.jinja b/invenio_communities/templates/semantic-ui/invenio_notifications/community-membership-request.submit.jinja new file mode 100644 index 000000000..debc4eb6b --- /dev/null +++ b/invenio_communities/templates/semantic-ui/invenio_notifications/community-membership-request.submit.jinja @@ -0,0 +1,62 @@ +{% set request = notification.context.request %} +{% set community = request.receiver %} +{% set created_by = request.created_by %} +{% set request_id = request.id %} +{% set community_title = community.metadata.title %} +{% set message = notification.context.message | safe if notification.context.message else '' %} +{% set role = notification.context.role %} +{% set user_name = created_by.username or created_by.profile.full_name %} + +{# WARNING: this uses a config set by invenio-app-rdm (not a dependency) to at least not hardcode the URL #} +{# invenio-app-rdm should have been using REQUESTS_ROUTES a config of a dependency however. #} +{% set request_link_path = config.RDM_REQUESTS_ROUTES["community-dashboard-request-details"].lstrip("/") %} +{% set request_link_path = request_link_path.replace("", community.id).replace("", request_id) %} +{% set request_link = "{ui}/{request_link_path}".format(ui=config.SITE_UI_URL, request_link_path=request_link_path) %} + +{# "/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 -%} +{#- What is our quote/no quote policy? -#} +{{ _("📬 New request for '@{user_name}' to join the community '{community_title}'").format(community_title=community_title, user_name=user_name) }} +{%- endblock subject -%} + +{%- block html_body -%} + + + + + + {% if message %} + + {% endif %} + + + + + + + + + + +
{{ _("'@{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}}"
{{ _("Check out the membership request")}}
_
{{ _("This is an auto-generated message. To manage notifications, visit your")}} {{ _("account settings")}}.
+{%- endblock html_body %} + +{%- block plain_body -%} +{{ _("'@{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:") }} {{message}}{% endif %} + +{{ _("Check out the membership request:") }} {{ request_link }} +{%- endblock plain_body %} + +{# Markdown for Slack/Mattermost/chat #} +{%- block md_body -%} +{{ _("*@{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:")}} {{ message }}{% endif %} + +[{{ _("Check out the membership request") }}]({{ request_link }}) +{%- endblock md_body %} diff --git a/tests/conftest.py b/tests/conftest.py index ec066bc57..a1d004952 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,6 +42,7 @@ CommunityInvitationDeclineNotificationBuilder, CommunityInvitationExpireNotificationBuilder, CommunityInvitationSubmittedNotificationBuilder, + CommunityMembershipRequestSubmittedNotificationBuilder, ) from invenio_communities.proxies import current_communities @@ -123,6 +124,7 @@ def app_config(app_config): CommunityInvitationDeclineNotificationBuilder.type: CommunityInvitationDeclineNotificationBuilder, CommunityInvitationExpireNotificationBuilder.type: CommunityInvitationExpireNotificationBuilder, CommunityInvitationSubmittedNotificationBuilder.type: CommunityInvitationSubmittedNotificationBuilder, + CommunityMembershipRequestSubmittedNotificationBuilder.type: CommunityMembershipRequestSubmittedNotificationBuilder, } # Specifying default resolvers. Will only be used in specific test cases. @@ -145,6 +147,14 @@ def app_config(app_config): app_config["COMMUNITIES_ALLOW_MEMBERSHIP_REQUESTS"] = True + # The responsibility of defining requests routes is in invenio-app-rdm i.e. + # any app that uses this module, so fair to define in configuration. + app_config["RDM_REQUESTS_ROUTES"] = { + "user-dashboard-request-details": "/me/requests/", + "community-dashboard-request-details": "/communities//requests/", + "community-dashboard-invitation-details": "/communities//invitations/", + } + return app_config diff --git a/tests/members/test_members_notifications.py b/tests/members/test_members_notifications.py index 946e5a355..03b7f96c0 100644 --- a/tests/members/test_members_notifications.py +++ b/tests/members/test_members_notifications.py @@ -6,8 +6,10 @@ # Invenio-Communities is free software; you can redistribute it and/or modify # it under the terms of the MIT License; see LICENSE file for more details. +from functools import reduce from unittest.mock import MagicMock +import pytest from invenio_access.permissions import system_identity from invenio_notifications.proxies import current_notifications_manager @@ -17,9 +19,39 @@ CommunityInvitationDeclineNotificationBuilder, CommunityInvitationExpireNotificationBuilder, CommunityInvitationSubmittedNotificationBuilder, + CommunityMembershipRequestAcceptNotificationBuilder, + CommunityMembershipRequestCancelNotificationBuilder, + CommunityMembershipRequestDeclineNotificationBuilder, + CommunityMembershipRequestExpireNotificationBuilder, + CommunityMembershipRequestSubmittedNotificationBuilder, ) +@pytest.fixture() +def setup_mock_of_notification_builder(monkeypatch): + """Factory fixture for setting up the builder under test and getting mock.""" + + def _inner(builder_cls): + """Do actual work.""" + # mock build to observe calls + mock_build = MagicMock() + mock_build.side_effect = builder_cls.build + monkeypatch.setattr(builder_cls, "build", mock_build) + # setting specific builder for test case + monkeypatch.setattr( + current_notifications_manager, + "builders", + { + **current_notifications_manager.builders, + builder_cls.type: builder_cls, + }, + ) + assert not mock_build.called + return mock_build + + return _inner + + # # invenio-notification testcases # @@ -336,3 +368,241 @@ def test_community_invitation_expire_notification( ) in html ) + + +# +# Membership requests cases +# + + +def test_request_membership_submit_notification( + setup_mock_of_notification_builder, + member_service, + community, + create_user, + members, # to make sure manager exists + db, + app, + clean_index, # instead of search_clear because module fixtures present +): + mock_build = setup_mock_of_notification_builder( + CommunityMembershipRequestSubmittedNotificationBuilder + ) + mail = app.extensions.get("mail") + assert mail + new_user = create_user() + + # Validate that notification email was sent + with mail.record_messages() as outbox: + message = "

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 + )