From 0e7dc130fb9321fcfc00ff61dfc9b04011b9aa35 Mon Sep 17 00:00:00 2001 From: Guillaume Viger Date: Wed, 1 May 2024 09:36:45 -0400 Subject: [PATCH] js+service: [#855] 4) integrate request flow with frontend [+] This concludes the 2nd flow of the membership request feature. Remaining flows are - 'waiting for decision' flow - 'making a decision' flow This PR needs to be complemented by: - one in invenio-requests (done) - one in invenio-rdm-records (to do) --- .../api/CommunityLinksExtractor.js | 8 + .../api/RequestLinksExtractor.js | 29 ++ .../api/membershipRequests/api.js | 26 ++ .../header/RequestMembershipButton.js | 74 +++- .../communities/services/config.py | 3 + .../members/services/request.py | 21 ++ .../members/services/service.py | 18 +- setup.cfg | 2 +- tests/members/conftest.py | 15 + tests/members/test_members_notifications.py | 338 +++++++++++++++++ tests/members/test_members_resource.py | 7 - tests/members/test_members_services.py | 350 +++--------------- 12 files changed, 557 insertions(+), 334 deletions(-) create mode 100644 invenio_communities/assets/semantic-ui/js/invenio_communities/api/RequestLinksExtractor.js create mode 100644 invenio_communities/assets/semantic-ui/js/invenio_communities/api/membershipRequests/api.js create mode 100644 tests/members/test_members_notifications.py diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/api/CommunityLinksExtractor.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/api/CommunityLinksExtractor.js index c831f9f77..67320a86f 100644 --- a/invenio_communities/assets/semantic-ui/js/invenio_communities/api/CommunityLinksExtractor.js +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/api/CommunityLinksExtractor.js @@ -35,4 +35,12 @@ export class CommunityLinksExtractor { } return this.#urls.invitations; } + + url(key) { + const urlOfKey = this.#urls[key]; + if (!urlOfKey) { + throw TypeError(`"${key}" link missing from resource.`); + } + return urlOfKey; + } } diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/api/RequestLinksExtractor.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/api/RequestLinksExtractor.js new file mode 100644 index 000000000..21267ad39 --- /dev/null +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/api/RequestLinksExtractor.js @@ -0,0 +1,29 @@ +// This file is part of Invenio-communities +// Copyright (C) 2024 Northwestern University. +// +// 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. + +export class RequestLinksExtractor { + #urls; + + constructor(request) { + if (!request?.links) { + throw TypeError("Request resource links are undefined"); + } + this.#urls = request.links; + } + + url(key) { + const urlOfKey = this.#urls[key]; + if (!urlOfKey) { + throw TypeError(`"${key}" link missing from resource.`); + } + return urlOfKey; + } + + get userDiscussionUrl() { + const result = this.url("self_html"); + return result.replace("/requests/", "/me/requests/"); + } +} diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/api/membershipRequests/api.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/api/membershipRequests/api.js new file mode 100644 index 000000000..c9954d29f --- /dev/null +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/api/membershipRequests/api.js @@ -0,0 +1,26 @@ +// This file is part of Invenio-communities +// Copyright (C) 2022 CERN. +// Copyright (C) 2024 Northwestern University. +// +// 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. + +import { CommunityLinksExtractor } from "../CommunityLinksExtractor"; +import { http } from "react-invenio-forms"; + +/** + * API Client for community membership requests. + * + * It mostly uses the API links passed to it from initial community. + * + */ +export class CommunityMembershipRequestsApi { + constructor(community) { + this.community = community; + this.linksExtractor = new CommunityLinksExtractor(community); + } + + requestMembership = async (payload) => { + return await http.post(this.linksExtractor.url("membership_requests"), payload); + }; +} diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/community/header/RequestMembershipButton.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/community/header/RequestMembershipButton.js index 42c85e39f..d09c5c8f2 100644 --- a/invenio_communities/assets/semantic-ui/js/invenio_communities/community/header/RequestMembershipButton.js +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/community/header/RequestMembershipButton.js @@ -12,23 +12,49 @@ import { Formik } from "formik"; import PropTypes from "prop-types"; import React, { useState } from "react"; import { TextAreaField } from "react-invenio-forms"; -import { Button, Form, Modal } from "semantic-ui-react"; +import { Button, Form, Grid, Message, Modal } from "semantic-ui-react"; + +import { CommunityMembershipRequestsApi } from "../../api/membershipRequests/api"; +import { communityErrorSerializer } from "../../api/serializers"; +import { RequestLinksExtractor } from "../../api/RequestLinksExtractor"; export function RequestMembershipModal(props) { - const { isOpen, onClose } = props; + const [errorMsg, setErrorMsg] = useState(""); + + const { community, isOpen, onClose } = props; const onSubmit = async (values, { setSubmitting, setFieldError }) => { - // TODO: implement me - console.log("RequestMembershipModal.onSubmit(args) called"); - console.log("TODO: implement me", arguments); - }; + /**Submit callback called from Formik. */ + setSubmitting(true); + + const client = new CommunityMembershipRequestsApi(community); + + try { + const response = await client.requestMembership(values); + const linksExtractor = new RequestLinksExtractor(response.data); + window.location.href = linksExtractor.userDiscussionUrl; + } catch (error) { + setSubmitting(false); + + console.log("Error"); + console.dir(error); - let confirmed = true; + const { errors, message } = communityErrorSerializer(error); + + if (message) { + setErrorMsg(message); + } + + if (errors) { + errors.forEach(({ field, messages }) => setFieldError(field, messages[0])); + } + } + }; return ( @@ -42,9 +68,17 @@ export function RequestMembershipModal(props) { > {i18next.t("Request Membership")} + +
@@ -54,12 +88,12 @@ export function RequestMembershipModal(props) { {i18next.t("Cancel")} @@ -73,10 +107,12 @@ export function RequestMembershipModal(props) { RequestMembershipModal.propTypes = { isOpen: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, + community: PropTypes.object.isRequired, }; export function RequestMembershipButton(props) { const [isModalOpen, setModalOpen] = useState(false); + const { community } = props; const handleClick = () => { setModalOpen(true); @@ -97,8 +133,16 @@ export function RequestMembershipButton(props) { content={i18next.t("Request Membership")} /> {isModalOpen && ( - + )} ); } + +RequestMembershipButton.propTypes = { + community: PropTypes.object.isRequired, +}; diff --git a/invenio_communities/communities/services/config.py b/invenio_communities/communities/services/config.py index 34cd0c807..fdd266a52 100644 --- a/invenio_communities/communities/services/config.py +++ b/invenio_communities/communities/services/config.py @@ -114,6 +114,9 @@ class CommunityServiceConfig(RecordServiceConfig, ConfiguratorMixin): "invitations": CommunityLink("{+api}/communities/{id}/invitations"), "requests": CommunityLink("{+api}/communities/{id}/requests"), "records": CommunityLink("{+api}/communities/{id}/records"), + "membership_requests": CommunityLink( + "{+api}/communities/{id}/membership-requests" + ), } action_link = CommunityLink( diff --git a/invenio_communities/members/services/request.py b/invenio_communities/members/services/request.py index ddc7d181b..7d193c418 100644 --- a/invenio_communities/members/services/request.py +++ b/invenio_communities/members/services/request.py @@ -29,6 +29,11 @@ def service(): return current_communities.service.members +# +# CommunityInvitation: actions and request type +# + + # # Actions # @@ -126,6 +131,21 @@ class CommunityInvitation(RequestType): } +# +# MembershipRequestRequestType: actions and request type +# + + +class CancelMembershipRequestAction(actions.CancelAction): + """Cancel membership request action.""" + + def execute(self, identity, uow): + """Execute action.""" + service().close_membership_request(system_identity, self.request.id, uow=uow) + # TODO: Investigate notifications + super().execute(identity, uow) + + class MembershipRequestRequestType(RequestType): """Request type for membership requests.""" @@ -135,6 +155,7 @@ class MembershipRequestRequestType(RequestType): create_action = "create" available_actions = { "create": actions.CreateAndSubmitAction, + "cancel": CancelMembershipRequestAction, } creator_can_be_none = False diff --git a/invenio_communities/members/services/service.py b/invenio_communities/members/services/service.py index 1c05bd28d..24255467b 100644 --- a/invenio_communities/members/services/service.py +++ b/invenio_communities/members/services/service.py @@ -843,7 +843,17 @@ def accept_membership_request(self, identity, request_id, uow=None): pass @unit_of_work() - def decline_membership_request(self, identity, request_id, uow=None): - """Decline membership request.""" - # TODO: Implement me - pass + def close_membership_request(self, identity, request_id, uow=None): + """Close membership request. + + Used for cancelling, declining, or expiring a membership request. + + For now we just delete the "fake" member that was created in + request_membership. TODO: explore alternatives/ramifications at a + later point. + """ + # Permissions are checked on the request action + assert identity == system_identity + member = self.record_cls.get_member_by_request(request_id) + assert member.active is False + uow.register(RecordDeleteOp(member, indexer=self.indexer, force=True)) diff --git a/setup.cfg b/setup.cfg index 5ce5c4590..8091f7513 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,7 +28,7 @@ python_requires = >=3.8 zip_safe = False install_requires = invenio-oaiserver>=2.2.0,<3.0.0 - invenio-requests>=4.0.0,<5.0.0 + invenio-requests>=4.2.0,<5.0.0 invenio-search-ui>=2.4.0,<3.0.0 invenio-vocabularies>=4.0.0,<5.0.0 invenio-administration>=2.0.0,<3.0.0 diff --git a/tests/members/conftest.py b/tests/members/conftest.py index dcb7d69cf..b916932ab 100644 --- a/tests/members/conftest.py +++ b/tests/members/conftest.py @@ -16,6 +16,7 @@ from invenio_access.permissions import system_identity from invenio_requests.records.api import Request from invenio_search import current_search +from invenio_users_resources.proxies import current_users_service from invenio_communities.members.records.api import ArchivedInvitation, Member @@ -93,3 +94,17 @@ def invite_request_id(requests_service, invite_user): type="community-invitation", ).to_dict() return res["hits"]["hits"][0]["id"] + + +@pytest.fixture(scope="function") +def membership_request(member_service, community, create_user, db, search_clear): + """A membership request.""" + user = create_user() + data = { + "message": "Can I join the club?", + } + return member_service.request_membership( + user.identity, + community._record.id, + data, + ) diff --git a/tests/members/test_members_notifications.py b/tests/members/test_members_notifications.py new file mode 100644 index 000000000..946e5a355 --- /dev/null +++ b/tests/members/test_members_notifications.py @@ -0,0 +1,338 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 Northwestern University. +# Copyright (C) 2022-2023 Graz University of Technology. +# +# 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 unittest.mock import MagicMock + +from invenio_access.permissions import system_identity +from invenio_notifications.proxies import current_notifications_manager + +from invenio_communities.notifications.builders import ( + CommunityInvitationAcceptNotificationBuilder, + CommunityInvitationCancelNotificationBuilder, + CommunityInvitationDeclineNotificationBuilder, + CommunityInvitationExpireNotificationBuilder, + CommunityInvitationSubmittedNotificationBuilder, +) + + +# +# invenio-notification testcases +# +def test_community_invitation_submit_notification( + member_service, + requests_service, + community, + owner, + new_user, + db, + monkeypatch, + app, + clean_index, +): + """Test notifcation being built on community invitation submit.""" + + original_builder = CommunityInvitationSubmittedNotificationBuilder + + # mock build to observe calls + mock_build = MagicMock() + mock_build.side_effect = original_builder.build + monkeypatch.setattr(original_builder, "build", mock_build) + # setting specific builder for test case + monkeypatch.setattr( + current_notifications_manager, + "builders", + { + **current_notifications_manager.builders, + original_builder.type: original_builder, + }, + ) + assert not mock_build.called + + mail = app.extensions.get("mail") + assert mail + + with mail.record_messages() as outbox: + # Validate that email was sent + role = "reader" + message = "

invitation message

" + + data = { + "members": [{"type": "user", "id": str(new_user.id)}], + "role": role, + "message": message, + } + member_service.invite(owner.identity, community.id, data) + # ensure that the invited user request has been indexed + res = member_service.search_invitations(owner.identity, community.id).to_dict() + assert res["hits"]["total"] == 1 + inv = res["hits"]["hits"][0] + + # check notification is build on submit + assert mock_build.called + assert len(outbox) == 1 + html = outbox[0].html + # TODO: update to `req["links"]["self_html"]` when addressing https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 + assert "/me/requests/{}".format(inv["request"]["id"]) in html + # role titles will be capitalized + assert role.capitalize() in html + assert "You have been invited to join" in html + assert message in html + assert community["metadata"]["title"] in html + + # decline to reset + requests_service.execute_action(new_user.identity, inv["request"]["id"], "decline") + with mail.record_messages() as outbox: + data = { + "members": [{"type": "user", "id": str(new_user.id)}], + "role": role, + } + # invite again without message + member_service.invite(owner.identity, community.id, data) + # ensure that the invited user request has been indexed + res = member_service.search_invitations(owner.identity, community.id).to_dict() + assert res["hits"]["total"] == 2 + inv = res["hits"]["hits"][1] + + # check notification is build on submit + assert mock_build.called + assert len(outbox) == 1 + html = outbox[0].html + # TODO: update to `req["links"]["self_html"]` when addressing https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 + assert "/me/requests/{}".format(inv["request"]["id"]) in html + # role titles will be capitalized + assert role.capitalize() in html + assert "You have been invited to join" in html + assert "with the following message:" not in html + assert community["metadata"]["title"] in html + + +def test_community_invitation_accept_notification( + member_service, + requests_service, + community, + new_user, + db, + monkeypatch, + app, + members, + clean_index, +): + """Test notifcation sent on community invitation accept.""" + + original_builder = CommunityInvitationAcceptNotificationBuilder + + owner = members["owner"] + # mock build to observe calls + mock_build = MagicMock() + mock_build.side_effect = original_builder.build + monkeypatch.setattr(original_builder, "build", mock_build) + assert not mock_build.called + + mail = app.extensions.get("mail") + assert mail + + role = "reader" + data = { + "members": [{"type": "user", "id": str(new_user.id)}], + "role": role, + } + member_service.invite(owner.identity, community.id, data) + res = member_service.search_invitations(owner.identity, community.id).to_dict() + assert res["hits"]["total"] == 1 + inv = res["hits"]["hits"][0] + with mail.record_messages() as outbox: + # Validate that email was sent + requests_service.execute_action( + new_user.identity, inv["request"]["id"], "accept" + ) + # check notification is build on submit + assert mock_build.called + # community owner, manager get notified + assert len(outbox) == 2 + html = outbox[0].html + # TODO: update to `req["links"]["self_html"]` when addressing https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 + assert "/me/requests/{}".format(inv["request"]["id"]) in html + # role titles will be capitalized + assert ( + "'@{who}' accepted the invitation to join your community '{title}'".format( + who=new_user.user.username + or new_user.user.user_profile.get("full_name"), + title=community["metadata"]["title"], + ) + in html + ) + + +def test_community_invitation_cancel_notification( + member_service, + requests_service, + community, + owner, + new_user, + db, + monkeypatch, + app, + clean_index, +): + """Test notifcation sent on community invitation cancel.""" + + original_builder = CommunityInvitationCancelNotificationBuilder + + # mock build to observe calls + mock_build = MagicMock() + mock_build.side_effect = original_builder.build + monkeypatch.setattr(original_builder, "build", mock_build) + assert not mock_build.called + + mail = app.extensions.get("mail") + assert mail + + role = "reader" + data = { + "members": [{"type": "user", "id": str(new_user.id)}], + "role": role, + } + + member_service.invite(owner.identity, community.id, data) + res = member_service.search_invitations(owner.identity, community.id).to_dict() + assert res["hits"]["total"] == 1 + inv = res["hits"]["hits"][0] + with mail.record_messages() as outbox: + # Validate that email was sent + requests_service.execute_action(owner.identity, inv["request"]["id"], "cancel") + # check notification is build on submit + assert mock_build.called + # invited user gets notified + assert len(outbox) == 1 + html = outbox[0].html + # TODO: update to `req["links"]["self_html"]` when addressing https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 + assert "/me/requests/{}".format(inv["request"]["id"]) in html + # role titles will be capitalized + assert ( + "The invitation for '@{who}' to join community '{title}' was cancelled".format( + who=new_user.user.username + or new_user.user.user_profile.get("full_name"), + title=community["metadata"]["title"], + ) + in html + ) + + +def test_community_invitation_decline_notification( + member_service, + requests_service, + community, + new_user, + db, + monkeypatch, + app, + members, + clean_index, +): + """Test notifcation sent on community invitation decline.""" + + owner = members["owner"] + original_builder = CommunityInvitationDeclineNotificationBuilder + + # mock build to observe calls + mock_build = MagicMock() + mock_build.side_effect = original_builder.build + monkeypatch.setattr(original_builder, "build", mock_build) + assert not mock_build.called + + mail = app.extensions.get("mail") + assert mail + + role = "reader" + data = { + "members": [{"type": "user", "id": str(new_user.id)}], + "role": role, + } + member_service.invite(owner.identity, community.id, data) + res = member_service.search_invitations(owner.identity, community.id).to_dict() + assert res["hits"]["total"] == 1 + inv = res["hits"]["hits"][0] + with mail.record_messages() as outbox: + # Validate that email was sent + # Added resp + resp = requests_service.execute_action( + new_user.identity, inv["request"]["id"], "decline" + ) + # check notification is build on submit + assert mock_build.called + # community owner, manager get notified + assert len(outbox) == 2 + html = outbox[0].html + # TODO: update to `req["links"]["self_html"]` when addressing https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 + assert "/me/requests/{}".format(inv["request"]["id"]) in html + # role titles will be capitalized + assert ( + "'@{who}' declined the invitation to join your community '{title}'".format( + who=new_user.user.username + or new_user.user.user_profile.get("full_name"), + title=community["metadata"]["title"], + ) + in html + ) + + +def test_community_invitation_expire_notification( + member_service, + requests_service, + community, + new_user, + db, + monkeypatch, + app, + members, + clean_index, +): + """Test notifcation sent on community invitation decline.""" + + owner = members["owner"] + original_builder = CommunityInvitationExpireNotificationBuilder + + # mock build to observe calls + mock_build = MagicMock() + mock_build.side_effect = original_builder.build + monkeypatch.setattr(original_builder, "build", mock_build) + assert not mock_build.called + + mail = app.extensions.get("mail") + assert mail + + role = "reader" + data = { + "members": [{"type": "user", "id": str(new_user.id)}], + "role": role, + } + member_service.invite(owner.identity, community.id, data) + res = member_service.search_invitations(owner.identity, community.id).to_dict() + assert res["hits"]["total"] == 1 + inv = res["hits"]["hits"][0] + with mail.record_messages() as outbox: + # Validate that email was sent + requests_service.execute_action(system_identity, inv["request"]["id"], "expire") + + # check notification is build on submit + assert mock_build.called + # community owner, manager and invited user get notified + # TODO: Replace with equivalent + assert len(outbox) == 3 + html = outbox[0].html + # TODO: update to `req["links"]["self_html"]` when addressing https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 + assert "/me/requests/{}".format(inv["request"]["id"]) in html + # role titles will be capitalized + assert ( + "The invitation for '@{who}' to join community '{title}' has expired.".format( + who=new_user.user.username + or new_user.user.user_profile.get("full_name"), + title=community["metadata"]["title"], + ) + in html + ) diff --git a/tests/members/test_members_resource.py b/tests/members/test_members_resource.py index ea75a911c..808b2dc36 100644 --- a/tests/members/test_members_resource.py +++ b/tests/members/test_members_resource.py @@ -417,14 +417,7 @@ def test_error_handling_for_membership_requests( assert True -# Is cancelling request purview of this? - - # TODO: search membership requests def test_get_membership_requests(client): # TODO: Implement me! assert True - # RequestEvent.index.refresh() - # r = client.get(f"/communities/{community_id}/membership-requests", headers=headers) - # assert r.status_code == 200 - # request_id = r.json["hits"]["hits"][0]["request"]["id"] diff --git a/tests/members/test_members_services.py b/tests/members/test_members_services.py index 69c40c81c..88d5e4b6f 100644 --- a/tests/members/test_members_services.py +++ b/tests/members/test_members_services.py @@ -1165,6 +1165,49 @@ def test_update_invalid_data(member_service, community, group): ) +# +# Membership requests +# Just a few choice tests given it's similar to other requests, and permissions have +# been tested elsewhere. +# + + +def test_request_cancel_request_flow( + member_service, + community, + create_user, + requests_service, + db, + search_clear, +): + """Check creation of membership request after first creation closed. + + This tests a temporary business rule that should be revisited later. + """ + # Create membership request + user = create_user() + data = { + "message": "Can I join the club?", + } + membership_request = member_service.request_membership( + user.identity, + community._record.id, + data, + ) + + # Close request - here via cancel + request = requests_service.execute_action( + user.identity, membership_request.id, "cancel" + ).to_dict() + + # Should be possible to create a new one again + membership_request_2 = member_service.request_membership( + user.identity, + community._record.id, + {"message": "Oops didn't mean to cancel. Oh well, I will request again."}, + ) + + # # Change notifications # @@ -1193,310 +1236,3 @@ def test_relation_update_propagation( member = list(comm_members.hits)[0] assert member.get("member").get("name") == "Update test" - - -# -# invenio-notification testcases -# -def test_community_invitation_submit_notification( - member_service, requests_service, community, owner, new_user, db, monkeypatch, app -): - """Test notifcation being built on community invitation submit.""" - - original_builder = CommunityInvitationSubmittedNotificationBuilder - - # mock build to observe calls - mock_build = MagicMock() - mock_build.side_effect = original_builder.build - monkeypatch.setattr(original_builder, "build", mock_build) - # setting specific builder for test case - monkeypatch.setattr( - current_notifications_manager, - "builders", - { - **current_notifications_manager.builders, - original_builder.type: original_builder, - }, - ) - assert not mock_build.called - - mail = app.extensions.get("mail") - assert mail - - with mail.record_messages() as outbox: - # Validate that email was sent - role = "reader" - message = "

invitation message

" - - data = { - "members": [{"type": "user", "id": str(new_user.id)}], - "role": role, - "message": message, - } - member_service.invite(owner.identity, community.id, data) - # ensure that the invited user request has been indexed - res = member_service.search_invitations(owner.identity, community.id).to_dict() - assert res["hits"]["total"] == 1 - inv = res["hits"]["hits"][0] - - # check notification is build on submit - assert mock_build.called - assert len(outbox) == 1 - html = outbox[0].html - # TODO: update to `req["links"]["self_html"]` when addressing https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 - assert "/me/requests/{}".format(inv["request"]["id"]) in html - # role titles will be capitalized - assert role.capitalize() in html - assert "You have been invited to join" in html - assert message in html - assert community["metadata"]["title"] in html - - # decline to reset - requests_service.execute_action(new_user.identity, inv["request"]["id"], "decline") - with mail.record_messages() as outbox: - data = { - "members": [{"type": "user", "id": str(new_user.id)}], - "role": role, - } - # invite again without message - member_service.invite(owner.identity, community.id, data) - # ensure that the invited user request has been indexed - res = member_service.search_invitations(owner.identity, community.id).to_dict() - assert res["hits"]["total"] == 2 - inv = res["hits"]["hits"][1] - - # check notification is build on submit - assert mock_build.called - assert len(outbox) == 1 - html = outbox[0].html - # TODO: update to `req["links"]["self_html"]` when addressing https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 - assert "/me/requests/{}".format(inv["request"]["id"]) in html - # role titles will be capitalized - assert role.capitalize() in html - assert "You have been invited to join" in html - assert "with the following message:" not in html - assert community["metadata"]["title"] in html - - -def test_community_invitation_accept_notification( - member_service, - requests_service, - community, - new_user, - db, - monkeypatch, - app, - members, - clean_index, -): - """Test notifcation sent on community invitation accept.""" - - original_builder = CommunityInvitationAcceptNotificationBuilder - - owner = members["owner"] - # mock build to observe calls - mock_build = MagicMock() - mock_build.side_effect = original_builder.build - monkeypatch.setattr(original_builder, "build", mock_build) - assert not mock_build.called - - mail = app.extensions.get("mail") - assert mail - - role = "reader" - data = { - "members": [{"type": "user", "id": str(new_user.id)}], - "role": role, - } - member_service.invite(owner.identity, community.id, data) - res = member_service.search_invitations(owner.identity, community.id).to_dict() - assert res["hits"]["total"] == 1 - inv = res["hits"]["hits"][0] - with mail.record_messages() as outbox: - # Validate that email was sent - requests_service.execute_action( - new_user.identity, inv["request"]["id"], "accept" - ) - # check notification is build on submit - assert mock_build.called - # community owner, manager get notified - assert len(outbox) == 2 - html = outbox[0].html - # TODO: update to `req["links"]["self_html"]` when addressing https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 - assert "/me/requests/{}".format(inv["request"]["id"]) in html - # role titles will be capitalized - assert ( - "'@{who}' accepted the invitation to join your community '{title}'".format( - who=new_user.user.username - or new_user.user.user_profile.get("full_name"), - title=community["metadata"]["title"], - ) - in html - ) - - -def test_community_invitation_cancel_notification( - member_service, - requests_service, - community, - owner, - new_user, - db, - monkeypatch, - app, - clean_index, -): - """Test notifcation sent on community invitation cancel.""" - - original_builder = CommunityInvitationCancelNotificationBuilder - - # mock build to observe calls - mock_build = MagicMock() - mock_build.side_effect = original_builder.build - monkeypatch.setattr(original_builder, "build", mock_build) - assert not mock_build.called - - mail = app.extensions.get("mail") - assert mail - - role = "reader" - data = { - "members": [{"type": "user", "id": str(new_user.id)}], - "role": role, - } - - member_service.invite(owner.identity, community.id, data) - res = member_service.search_invitations(owner.identity, community.id).to_dict() - assert res["hits"]["total"] == 1 - inv = res["hits"]["hits"][0] - with mail.record_messages() as outbox: - # Validate that email was sent - requests_service.execute_action(owner.identity, inv["request"]["id"], "cancel") - # check notification is build on submit - assert mock_build.called - # invited user gets notified - assert len(outbox) == 1 - html = outbox[0].html - # TODO: update to `req["links"]["self_html"]` when addressing https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 - assert "/me/requests/{}".format(inv["request"]["id"]) in html - # role titles will be capitalized - assert ( - "The invitation for '@{who}' to join community '{title}' was cancelled".format( - who=new_user.user.username - or new_user.user.user_profile.get("full_name"), - title=community["metadata"]["title"], - ) - in html - ) - - -def test_community_invitation_decline_notification( - member_service, - requests_service, - community, - new_user, - db, - monkeypatch, - app, - members, - clean_index, -): - """Test notifcation sent on community invitation decline.""" - - owner = members["owner"] - original_builder = CommunityInvitationDeclineNotificationBuilder - - # mock build to observe calls - mock_build = MagicMock() - mock_build.side_effect = original_builder.build - monkeypatch.setattr(original_builder, "build", mock_build) - assert not mock_build.called - - mail = app.extensions.get("mail") - assert mail - - role = "reader" - data = { - "members": [{"type": "user", "id": str(new_user.id)}], - "role": role, - } - member_service.invite(owner.identity, community.id, data) - res = member_service.search_invitations(owner.identity, community.id).to_dict() - assert res["hits"]["total"] == 1 - inv = res["hits"]["hits"][0] - with mail.record_messages() as outbox: - # Validate that email was sent - requests_service.execute_action( - new_user.identity, inv["request"]["id"], "decline" - ) - # check notification is build on submit - assert mock_build.called - # community owner, manager get notified - assert len(outbox) == 2 - html = outbox[0].html - # TODO: update to `req["links"]["self_html"]` when addressing https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 - assert "/me/requests/{}".format(inv["request"]["id"]) in html - # role titles will be capitalized - assert ( - "'@{who}' declined the invitation to join your community '{title}'".format( - who=new_user.user.username - or new_user.user.user_profile.get("full_name"), - title=community["metadata"]["title"], - ) - in html - ) - - -def test_community_invitation_expire_notification( - member_service, - requests_service, - community, - new_user, - db, - monkeypatch, - app, - members, - clean_index, -): - """Test notifcation sent on community invitation decline.""" - - owner = members["owner"] - original_builder = CommunityInvitationExpireNotificationBuilder - - # mock build to observe calls - mock_build = MagicMock() - mock_build.side_effect = original_builder.build - monkeypatch.setattr(original_builder, "build", mock_build) - assert not mock_build.called - - mail = app.extensions.get("mail") - assert mail - - role = "reader" - data = { - "members": [{"type": "user", "id": str(new_user.id)}], - "role": role, - } - member_service.invite(owner.identity, community.id, data) - res = member_service.search_invitations(owner.identity, community.id).to_dict() - assert res["hits"]["total"] == 1 - inv = res["hits"]["hits"][0] - with mail.record_messages() as outbox: - # Validate that email was sent - requests_service.execute_action(system_identity, inv["request"]["id"], "expire") - # check notification is build on submit - assert mock_build.called - # community owner, manager and invited user get notified - assert len(outbox) == 3 - html = outbox[0].html - # TODO: update to `req["links"]["self_html"]` when addressing https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 - assert "/me/requests/{}".format(inv["request"]["id"]) in html - # role titles will be capitalized - assert ( - "The invitation for '@{who}' to join community '{title}' has expired.".format( - who=new_user.user.username - or new_user.user.user_profile.get("full_name"), - title=community["metadata"]["title"], - ) - in html - )