From 0b33e3f3dab7bf93bc0d96fd3a69d60ae91814ff Mon Sep 17 00:00:00 2001 From: Guillaume Viger Date: Tue, 23 Apr 2024 11:26:27 -0400 Subject: [PATCH 1/8] settings-ui: [#855] set membership policy --- ...dgesForm.js => CommunityPrivilegesForm.js} | 68 ++++++++++++++++++- .../settings/privileges/index.js | 2 +- invenio_communities/config.py | 3 + invenio_communities/views/communities.py | 25 +++++++ 4 files changed, 94 insertions(+), 4 deletions(-) rename invenio_communities/assets/semantic-ui/js/invenio_communities/settings/privileges/{CommunityPriviledgesForm.js => CommunityPrivilegesForm.js} (70%) diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/settings/privileges/CommunityPriviledgesForm.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/settings/privileges/CommunityPrivilegesForm.js similarity index 70% rename from invenio_communities/assets/semantic-ui/js/invenio_communities/settings/privileges/CommunityPriviledgesForm.js rename to invenio_communities/assets/semantic-ui/js/invenio_communities/settings/privileges/CommunityPrivilegesForm.js index ce4d30036..c609ff091 100644 --- a/invenio_communities/assets/semantic-ui/js/invenio_communities/settings/privileges/CommunityPriviledgesForm.js +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/settings/privileges/CommunityPrivilegesForm.js @@ -10,6 +10,7 @@ import { i18next } from "@translations/invenio_communities/i18next"; import { CommunitySettingsForm } from "..//components/CommunitySettingsForm"; import _get from "lodash/get"; +import _isEmpty from "lodash/isEmpty"; import { useField } from "formik"; import React, { Component } from "react"; import { RadioField } from "react-invenio-forms"; @@ -18,17 +19,31 @@ import PropTypes from "prop-types"; const VisibilityField = ({ label, formConfig, ...props }) => { const [field] = useField(props); + const fieldPath = "access.visibility"; + + function createHandleChange(radioValue) { + function handleChange({ event, data, formikProps }) { + formikProps.form.setFieldValue(fieldPath, radioValue); + // dependent fields + if (radioValue === "restricted") { + formikProps.form.setFieldValue("access.member_policy", "closed"); + } + } + return handleChange; + } + return ( <> {formConfig.access.visibility.map((item) => ( @@ -76,14 +91,47 @@ MembersVisibilityField.defaultProps = { label: "", }; +const MemberPolicyField = ({ label, formConfig, ...props }) => { + const [field] = useField(props); + const isDisabled = _get(field.value, "access.visibility") === "restricted"; + + return ( + <> + {formConfig.access.member_policy.map((item) => ( + + + + + ))} + + ); +}; + +MemberPolicyField.propTypes = { + label: PropTypes.string, + formConfig: PropTypes.object.isRequired, +}; + +MemberPolicyField.defaultProps = { + label: "", +}; + class CommunityPrivilegesForm extends Component { getInitialValues = () => { return { access: { visibility: "public", members_visibility: "public", + member_policy: "closed", // TODO: Re-enable once properly integrated to be displayed - // member_policy: "open", // record_policy: "open", }, }; @@ -105,6 +153,7 @@ class CommunityPrivilegesForm extends Component { +
{i18next.t("Members visibility")} @@ -114,6 +163,19 @@ class CommunityPrivilegesForm extends Component {
+ + {!_isEmpty(formConfig.access.member_policy) && ( + <> +
+ {i18next.t("Membership Policy")} + + {i18next.t("Controls if anyone can request to join your community.")} + +
+ + + )} + {/* TODO: Re-enable once properly integrated to be displayed */} {/* diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/settings/privileges/index.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/settings/privileges/index.js index ecbfcbd2d..fff65cd03 100644 --- a/invenio_communities/assets/semantic-ui/js/invenio_communities/settings/privileges/index.js +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/settings/privileges/index.js @@ -1,4 +1,4 @@ -import CommunityPrivilegesForm from "./CommunityPriviledgesForm"; +import CommunityPrivilegesForm from "./CommunityPrivilegesForm"; import ReactDOM from "react-dom"; import React from "react"; diff --git a/invenio_communities/config.py b/invenio_communities/config.py index 1e9185740..bb325fb82 100644 --- a/invenio_communities/config.py +++ b/invenio_communities/config.py @@ -314,3 +314,6 @@ COMMUNITIES_ALWAYS_SHOW_CREATE_LINK = False """Controls visibility of 'New Community' btn based on user's permission when set to True.""" + +COMMUNITIES_ALLOW_MEMBERSHIP_REQUESTS = False +"""Feature flag for membership request.""" diff --git a/invenio_communities/views/communities.py b/invenio_communities/views/communities.py index 5cf36793a..8795f28b0 100644 --- a/invenio_communities/views/communities.py +++ b/invenio_communities/views/communities.py @@ -82,6 +82,24 @@ ] +MEMBER_POLICY_FIELDS = [ + { + "text": "Open", + "value": "open", + "icon": "user plus", + "helpText": _("Users can request to join your community."), + }, + { + "text": "Closed", + "value": "closed", + "icon": "user times", + "helpText": _( + "Users cannot request to join your community. Only invited users can become members of your community." + ), + }, +] + + HEADER_PERMISSIONS = { "read", "update", @@ -341,6 +359,12 @@ def communities_settings_privileges(pid_value, community, community_ui): if not permissions["can_manage_access"]: raise PermissionDeniedError() + member_policy = ( + MEMBER_POLICY_FIELDS + if current_app.config["COMMUNITIES_ALLOW_MEMBERSHIP_REQUESTS"] + else {} + ) + return render_community_theme_template( "invenio_communities/details/settings/privileges.html", theme=community_ui.get("theme", {}), @@ -349,6 +373,7 @@ def communities_settings_privileges(pid_value, community, community_ui): access=dict( visibility=VISIBILITY_FIELDS, members_visibility=MEMBERS_VISIBILITY_FIELDS, + member_policy=member_policy, ), ), permissions=permissions, From 51255d18c8ccea67a91162eb6539beaed318d190 Mon Sep 17 00:00:00 2001 From: Guillaume Viger Date: Mon, 29 Apr 2024 15:04:27 -0400 Subject: [PATCH 2/8] header-ui: [#855] add inert request membership button+modal --- .../header/RequestMembershipButton.js | 104 ++++++++++++++++++ .../community/header/index.js | 21 ++++ .../invenio_communities/details/base.html | 5 + .../invenio_communities/details/header.html | 19 +++- invenio_communities/views/communities.py | 1 + invenio_communities/webpack.py | 1 + 6 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 invenio_communities/assets/semantic-ui/js/invenio_communities/community/header/RequestMembershipButton.js create mode 100644 invenio_communities/assets/semantic-ui/js/invenio_communities/community/header/index.js 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 new file mode 100644 index 000000000..42c85e39f --- /dev/null +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/community/header/RequestMembershipButton.js @@ -0,0 +1,104 @@ +/* + * This file is part of Invenio. + * Copyright (C) 2024 CERN. + * Copyright (C) 2024 Northwestern University. + * + * Invenio 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 { i18next } from "@translations/invenio_communities/i18next"; +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"; + +export function RequestMembershipModal(props) { + const { isOpen, onClose } = props; + + const onSubmit = async (values, { setSubmitting, setFieldError }) => { + // TODO: implement me + console.log("RequestMembershipModal.onSubmit(args) called"); + console.log("TODO: implement me", arguments); + }; + + let confirmed = true; + + return ( + + {({ values, isSubmitting, handleSubmit }) => ( + + {i18next.t("Request Membership")} + +
+ + +
+ + + + +
+ )} +
+ ); +} + +RequestMembershipModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, +}; + +export function RequestMembershipButton(props) { + const [isModalOpen, setModalOpen] = useState(false); + + const handleClick = () => { + setModalOpen(true); + }; + + const handleClose = () => { + setModalOpen(false); + }; + + return ( + <> + @@ -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/conftest.py b/tests/conftest.py index 5d901f60f..ec066bc57 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -371,7 +371,7 @@ def create_user(UserFixture, app, db): is essential for many tests. """ - def _create_user(data): + def _create_user(data=None): """Create user.""" default_data = dict( email="user@example.org", @@ -391,6 +391,7 @@ def _create_user(data): active=True, confirmed=True, ) + data = data or {} actual_data = dict(default_data, **data) u = UserFixture(**actual_data) u.create(app, db) 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 - ) From ed95004668df897f99236ecaa0d8de85ed643530 Mon Sep 17 00:00:00 2001 From: Guillaume Viger Date: Wed, 1 May 2024 09:36:45 -0400 Subject: [PATCH 6/8] 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 (done) --- invenio_communities/members/services/request.py | 1 - 1 file changed, 1 deletion(-) diff --git a/invenio_communities/members/services/request.py b/invenio_communities/members/services/request.py index 7d193c418..2dba594e2 100644 --- a/invenio_communities/members/services/request.py +++ b/invenio_communities/members/services/request.py @@ -28,7 +28,6 @@ def service(): """Service.""" return current_communities.service.members - # # CommunityInvitation: actions and request type # From 67a28b46a2c0739dd0263e215cef499b9b74b965 Mon Sep 17 00:00:00 2001 From: Guillaume Viger Date: Fri, 19 Jul 2024 15:29:43 -0400 Subject: [PATCH 7/8] membership-request [#855]: implement wait for decision flow --- .../MembershipRequestsContextProvider.js | 33 +++ .../js/invenio_communities/members/Filters.js | 6 + .../members/MemberRequestsResults.js | 58 +++++ ...t.js => MemberRequestsSearchBarElement.js} | 17 +- .../invitations/InvitationResultItem.js | 29 ++- .../members/invitations/index.js | 49 +++- .../MembershipRequestsEmptyResults.js | 47 ++++ .../MembershipRequestsResultItem.js | 111 +++++++++ .../MembershipRequestsResultsContainer.js | 36 +++ .../MembershipRequestsSearchLayout.js | 65 +++++ .../members/membership_requests/index.js | 96 ++++++++ .../js/invenio_communities/members/utils.js | 40 ++++ .../communities/services/service.py | 2 + invenio_communities/config.py | 28 ++- invenio_communities/ext.py | 7 + invenio_communities/members/records/api.py | 27 ++- .../members/records/dumpers.py | 35 +++ .../archivedinvitation-v1.0.0.json | 3 + .../members/member-v1.0.0.json | 3 + .../archivedinvitation-v1.0.0.json | 3 + .../members/member-v1.0.0.json | 3 + .../archivedinvitation-v1.0.0.json | 3 + .../members/member-v1.0.0.json | 3 + .../members/resources/resource.py | 16 ++ .../members/services/config.py | 16 +- invenio_communities/members/services/links.py | 224 ++++++++++++++++++ .../members/services/request.py | 34 ++- .../members/services/schemas.py | 10 + .../members/services/service.py | 111 ++++++--- invenio_communities/permissions.py | 1 + invenio_communities/searchapp.py | 10 + .../invenio_communities/details/header.html | 26 +- .../details/members/base.html | 1 + .../details/members/membership_requests.html | 37 +++ invenio_communities/views/communities.py | 30 +++ invenio_communities/views/ui.py | 3 + invenio_communities/webpack.py | 1 + tests/members/test_members_resource.py | 54 ++++- tests/members/test_members_services.py | 82 ++++++- 39 files changed, 1264 insertions(+), 96 deletions(-) create mode 100644 invenio_communities/assets/semantic-ui/js/invenio_communities/api/membershipRequests/MembershipRequestsContextProvider.js create mode 100644 invenio_communities/assets/semantic-ui/js/invenio_communities/members/MemberRequestsResults.js rename invenio_communities/assets/semantic-ui/js/invenio_communities/members/{invitations/InvitationsSearchBarElement.js => MemberRequestsSearchBarElement.js} (73%) create mode 100644 invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/MembershipRequestsEmptyResults.js create mode 100644 invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/MembershipRequestsResultItem.js create mode 100644 invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/MembershipRequestsResultsContainer.js create mode 100644 invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/MembershipRequestsSearchLayout.js create mode 100644 invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/index.js create mode 100644 invenio_communities/assets/semantic-ui/js/invenio_communities/members/utils.js create mode 100644 invenio_communities/members/records/dumpers.py create mode 100644 invenio_communities/members/services/links.py create mode 100644 invenio_communities/templates/semantic-ui/invenio_communities/details/members/membership_requests.html diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/api/membershipRequests/MembershipRequestsContextProvider.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/api/membershipRequests/MembershipRequestsContextProvider.js new file mode 100644 index 000000000..c307c1cc2 --- /dev/null +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/api/membershipRequests/MembershipRequestsContextProvider.js @@ -0,0 +1,33 @@ +// 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 { CommunityMembershipRequestsApi } from "./api"; +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +export const MembershipRequestsContext = React.createContext({ api: undefined }); + +export class MembershipRequestsContextProvider extends Component { + constructor(props) { + super(props); + const { community } = props; + this.apiClient = new CommunityMembershipRequestsApi(community); + } + render() { + const { children } = this.props; + return ( + + {children} + + ); + } +} + +MembershipRequestsContextProvider.propTypes = { + community: PropTypes.object.isRequired, + children: PropTypes.node.isRequired, +}; diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/members/Filters.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/Filters.js index 62faaead5..db3843377 100644 --- a/invenio_communities/assets/semantic-ui/js/invenio_communities/members/Filters.js +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/Filters.js @@ -49,6 +49,12 @@ export class Filters { return { ...rolesFilters, ...statusFilters }; } + getMembershipRequestFilters() { + const statusFilters = this.getStatus(); + const rolesFilters = this.getRoles(); + return { ...rolesFilters, ...statusFilters }; + } + getMembersFilters() { const visibilityFilters = this.getVisibility(); const rolesFilters = this.getRoles(); diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/members/MemberRequestsResults.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/MemberRequestsResults.js new file mode 100644 index 000000000..53e468e01 --- /dev/null +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/MemberRequestsResults.js @@ -0,0 +1,58 @@ +/* + * This file is part of Invenio. + * Copyright (C) 2022 CERN. + * Copyright (C) 2024 Northwestern University. + * + * Invenio 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 React from "react"; +import { Grid } from "semantic-ui-react"; +import { ResultsPerPage, Pagination, ResultsList } from "react-searchkit"; +import PropTypes from "prop-types"; +import { Trans } from "react-i18next"; + +export const MemberRequestsResults = ({ paginationOptions, currentResultsState }) => { + const { total } = currentResultsState.data; + return ( + total && ( + + + + + + + + + + + + ( + // kept key for translation purposes - it should be + // the same across members, invitations, membership requests + // and beyond + + {cmp} results per page + + )} + /> + + + + ) + ); +}; + +MemberRequestsResults.propTypes = { + paginationOptions: PropTypes.object.isRequired, + currentResultsState: PropTypes.object.isRequired, +}; diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/members/invitations/InvitationsSearchBarElement.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/MemberRequestsSearchBarElement.js similarity index 73% rename from invenio_communities/assets/semantic-ui/js/invenio_communities/members/invitations/InvitationsSearchBarElement.js rename to invenio_communities/assets/semantic-ui/js/invenio_communities/members/MemberRequestsSearchBarElement.js index 8837e650b..36a37fb68 100644 --- a/invenio_communities/assets/semantic-ui/js/invenio_communities/members/invitations/InvitationsSearchBarElement.js +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/MemberRequestsSearchBarElement.js @@ -1,6 +1,7 @@ /* * This file is part of Invenio. * Copyright (C) 2022 CERN. + * Copyright (C) 2024 Northwestern University. * * Invenio is free software; you can redistribute it and/or modify it * under the terms of the MIT License; see LICENSE file for more details. @@ -11,16 +12,18 @@ import { Input } from "semantic-ui-react"; import { i18next } from "@translations/invenio_communities/i18next"; import PropTypes from "prop-types"; -export const InvitationsSearchBarElement = ({ +export const MemberRequestsSearchBarElement = ({ onBtnSearchClick, onInputChange, onKeyPress, queryString, uiProps, + className, + placeholder, }) => { return ( { onInputChange(value); }} @@ -39,14 +42,18 @@ export const InvitationsSearchBarElement = ({ ); }; -InvitationsSearchBarElement.propTypes = { +MemberRequestsSearchBarElement.propTypes = { onBtnSearchClick: PropTypes.func.isRequired, onInputChange: PropTypes.func.isRequired, onKeyPress: PropTypes.func.isRequired, queryString: PropTypes.string.isRequired, uiProps: PropTypes.object, + className: PropTypes.string, + placeholder: PropTypes.string, }; -InvitationsSearchBarElement.defaultProps = { +MemberRequestsSearchBarElement.defaultProps = { uiProps: null, + className: "", + placeholder: "", }; diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/members/invitations/InvitationResultItem.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/invitations/InvitationResultItem.js index f240e1cdb..db936c86c 100644 --- a/invenio_communities/assets/semantic-ui/js/invenio_communities/members/invitations/InvitationResultItem.js +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/invitations/InvitationResultItem.js @@ -1,24 +1,23 @@ /* * This file is part of Invenio. * Copyright (C) 2022 CERN. + * Copyright (C) 2024 Northwestern University. * * Invenio 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 { RequestActionController } from "@js/invenio_requests/request/actions/RequestActionController"; import { i18next } from "@translations/invenio_communities/i18next"; -import { DateTime } from "luxon"; import PropTypes from "prop-types"; import React, { Component } from "react"; import { Image } from "react-invenio-forms"; import { Container, Grid, Item, Table } from "semantic-ui-react"; import { InvitationsContext } from "../../api/invitations/InvitationsContextProvider"; import { RoleDropdown } from "../components/dropdowns"; +import { buildRequest, formattedTime } from "../utils"; import RequestStatus from "@js/invenio_requests/request/RequestStatus"; -const formattedTime = (expiresAt) => - DateTime.fromISO(expiresAt).setLocale(i18next.language).toRelative(); - export class InvitationResultItem extends Component { constructor(props) { super(props); @@ -39,12 +38,13 @@ export class InvitationResultItem extends Component { community, } = this.props; const { - invitation: { member, request }, + invitation: { member }, invitation, } = this.state; + const request = buildRequest(invitation, ["cancel"]); const { api: invitationsApi } = this.context; const rolesCanInviteByType = rolesCanInvite[member.type]; - const memberInvitationExpiration = formattedTime(request.expires_at); + const expiration = formattedTime(request.expires_at); return ( @@ -72,10 +72,10 @@ export class InvitationResultItem extends Component { - {memberInvitationExpiration} + {expiration} - {/* TODO uncomment when links available in the request resource subschema */} - {/**/} - {/**/} - {/**/} + { + window.location.reload(); + }} + /> diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/members/invitations/index.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/invitations/index.js index 34862a764..581ef18ea 100644 --- a/invenio_communities/assets/semantic-ui/js/invenio_communities/members/invitations/index.js +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/invitations/index.js @@ -1,21 +1,14 @@ /* * This file is part of Invenio. * Copyright (C) 2022 CERN. + * Copyright (C) 2024 Northwestern University. * * Invenio 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 { createSearchAppInit } from "@js/invenio_search_ui"; -import { parametrize, overrideStore } from "react-overridable"; -import { DropdownSort } from "@js/invenio_search_ui/components"; -import { InvitationsContextProvider as ContextProvider } from "../../api/invitations/InvitationsContextProvider"; -import { InvitationResultItem } from "./InvitationResultItem"; -import { InvitationsResults } from "./InvitationsResults"; -import { InvitationsResultsContainer } from "./InvitationsResultsContainer"; -import { InvitationsSearchBarElement } from "./InvitationsSearchBarElement"; -import { InvitationsSearchLayout } from "./InvitationsSearchLayout"; -import { InvitationsEmptyResults } from "./InvitationsEmptyResults"; +import { RequestCancelButton } from "@js/invenio_requests/components/Buttons"; +import { RequestCancelModalTrigger } from "@js/invenio_requests/components/ModalTriggers"; import { SubmitStatus, DeleteStatus, @@ -24,6 +17,20 @@ import { CancelStatus, ExpireStatus, } from "@js/invenio_requests/request"; +import { createSearchAppInit } from "@js/invenio_search_ui"; +import { DropdownSort } from "@js/invenio_search_ui/components"; +import { i18next } from "@translations/invenio_communities/i18next"; +import React from "react"; +import { Trans } from "react-i18next"; +import { parametrize, overrideStore } from "react-overridable"; + +import { InvitationsContextProvider as ContextProvider } from "../../api/invitations/InvitationsContextProvider"; +import { MemberRequestsSearchBarElement } from "../MemberRequestsSearchBarElement"; +import { InvitationsEmptyResults } from "./InvitationsEmptyResults"; +import { InvitationResultItem } from "./InvitationResultItem"; +import { InvitationsResults } from "./InvitationsResults"; +import { InvitationsResultsContainer } from "./InvitationsResultsContainer"; +import { InvitationsSearchLayout } from "./InvitationsSearchLayout"; const dataAttr = document.getElementById("community-invitations-search-root").dataset; const community = JSON.parse(dataAttr.community); @@ -49,6 +56,11 @@ const InvitationsSearchLayoutWithConfig = parametrize(InvitationsSearchLayout, { appName: appName, }); +const InvitationsSearchBarElement = parametrize(MemberRequestsSearchBarElement, { + className: "invitation-searchbar", + placeholder: i18next.t("Search in invitations..."), +}); + const InvitationsContextProvider = parametrize(ContextProvider, { community: community, }); @@ -65,14 +77,25 @@ const InvitationsEmptyResultsWithCommunity = parametrize(InvitationsEmptyResults rolesCanInvite: communitiesRolesCanInvite, }); +const InvitationsRequestCancelButton = parametrize(RequestCancelButton, { + content: i18next.t("Cancel invitation"), +}); + +const InvitationsRequestActionModalCancelTitle = (props) => { + return ; +}; + const defaultComponents = { [`${appName}.EmptyResults.element`]: InvitationsEmptyResultsWithCommunity, - [`${appName}.ResultsList.item`]: InvitationResultItemWithConfig, [`${appName}.SearchApp.layout`]: InvitationsSearchLayoutWithConfig, [`${appName}.SearchBar.element`]: InvitationsSearchBarElement, - [`${appName}.SearchApp.results`]: InvitationsResults, - [`${appName}.ResultsList.container`]: InvitationsResultsContainerWithConfig, [`${appName}.Sort.element`]: DropdownSort, + [`${appName}.ResultsList.container`]: InvitationsResultsContainerWithConfig, + [`${appName}.SearchApp.results`]: InvitationsResults, + [`${appName}.ResultsList.item`]: InvitationResultItemWithConfig, + "RequestActionModalTrigger.cancel": RequestCancelModalTrigger, + "RequestActionModal.title.cancel": InvitationsRequestActionModalCancelTitle, + "RequestActionButton.cancel": InvitationsRequestCancelButton, [`RequestStatus.layout.submitted`]: SubmitStatus, [`RequestStatus.layout.deleted`]: DeleteStatus, [`RequestStatus.layout.accepted`]: AcceptStatus, diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/MembershipRequestsEmptyResults.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/MembershipRequestsEmptyResults.js new file mode 100644 index 000000000..0b5e7e6f8 --- /dev/null +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/MembershipRequestsEmptyResults.js @@ -0,0 +1,47 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { Button, Header, Icon, Segment } from "semantic-ui-react"; +import { withState } from "react-searchkit"; +import { i18next } from "@translations/invenio_communities/i18next"; + +class MembershipRequestsEmptyResultsCmp extends Component { + render() { + const { resetQuery, extraContent, queryString } = this.props; + + return ( + + +
+ + {i18next.t("No matching members found.")} +
+ {queryString && ( +

+ + {i18next.t("Current search")} "{queryString}" + +

+ )} + + {extraContent} +
+
+ ); + } +} + +MembershipRequestsEmptyResultsCmp.propTypes = { + resetQuery: PropTypes.func.isRequired, + queryString: PropTypes.string.isRequired, + extraContent: PropTypes.node, +}; + +MembershipRequestsEmptyResultsCmp.defaultProps = { + extraContent: null, +}; + +export const MembershipRequestsEmptyResults = withState( + MembershipRequestsEmptyResultsCmp +); diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/MembershipRequestsResultItem.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/MembershipRequestsResultItem.js new file mode 100644 index 000000000..79ec348ad --- /dev/null +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/MembershipRequestsResultItem.js @@ -0,0 +1,111 @@ +/* + * This file is part of Invenio. + * Copyright (C) 2022 CERN. + * Copyright (C) 2024 Northwestern University. + * + * Invenio 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 { RequestActionController } from "@js/invenio_requests/request/actions/RequestActionController"; +import RequestStatus from "@js/invenio_requests/request/RequestStatus"; +import { i18next } from "@translations/invenio_communities/i18next"; +import PropTypes from "prop-types"; +import React, { Component } from "react"; +import { Image } from "react-invenio-forms"; +import { Grid, Item, Table } from "semantic-ui-react"; + +import { RoleDropdown } from "../components/dropdowns"; +import { formattedTime } from "../utils"; + +export class MembershipRequestsResultItem extends Component { + constructor(props) { + super(props); + const { result } = this.props; + this.state = { membershipRequest: result }; + } + + update = (data, value) => { + const { membershipRequest } = this.state; + this.setState({ membershipRequest: { ...membershipRequest, ...{ role: value } } }); + }; + + actionSuccessCallback = () => undefined; + + render() { + const { + config: { rolesCanAssign }, + community, + } = this.props; + + const { + membershipRequest: { member, request }, + membershipRequest, + } = this.state; + // TODO: Decision flow + // const { api: membershipRequestsApi } = this.context; + const rolesCanAssignByType = rolesCanAssign[member.type]; + const membershipRequestExpiration = formattedTime(request.expires_at); + return ( + + + + + + + + +
+ {member.name} + + + {member.description && ( + +
{member.description}
+
+ )} + + +
+ + + + + + + {membershipRequestExpiration} + + + + + + console.log("actionSuccessCallback called")} + /> + + + ); + } +} + +MembershipRequestsResultItem.propTypes = { + result: PropTypes.object.isRequired, + config: PropTypes.object.isRequired, + community: PropTypes.object.isRequired, +}; + +MembershipRequestsResultItem.defaultProps = {}; diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/MembershipRequestsResultsContainer.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/MembershipRequestsResultsContainer.js new file mode 100644 index 000000000..4108cc162 --- /dev/null +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/MembershipRequestsResultsContainer.js @@ -0,0 +1,36 @@ +/* + * This file is part of Invenio. + * Copyright (C) 2022 CERN. + * Copyright (C) 2024 Northwestern University. + * + * Invenio 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 { i18next } from "@translations/invenio_communities/i18next"; +import PropTypes from "prop-types"; +import React from "react"; +import { Table } from "semantic-ui-react"; + +export const MembershipRequestsResultsContainer = ({ results }) => { + return ( + + + + {i18next.t("Name")} + {i18next.t("Status")} + {i18next.t("Expires")} + {i18next.t("Role")} + {i18next.t("Actions")} + + + {results} +
+ ); +}; + +MembershipRequestsResultsContainer.propTypes = { + results: PropTypes.array.isRequired, +}; + +MembershipRequestsResultsContainer.defaultProps = {}; diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/MembershipRequestsSearchLayout.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/MembershipRequestsSearchLayout.js new file mode 100644 index 000000000..9f57c16b9 --- /dev/null +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/MembershipRequestsSearchLayout.js @@ -0,0 +1,65 @@ +/* + * This file is part of Invenio. + * Copyright (C) 2022 CERN. + * Copyright (C) 2024 Northwestern University. + * + * Invenio 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 { RequestStatusFilter } from "@js/invenio_requests/search"; +import { SearchAppResultsPane } from "@js/invenio_search_ui/components"; +import { SearchFilters } from "@js/invenio_search_ui/components/SearchFilters"; +import PropTypes from "prop-types"; +import React, { Component } from "react"; +import { SearchBar, Sort } from "react-searchkit"; + +import { Filters } from "../Filters"; +import { FilterLabels } from "../components/FilterLabels"; + +export class MembershipRequestsSearchLayout extends Component { + render() { + const { config, roles, appName } = this.props; + + const filtersClass = new Filters(roles); + const customFilters = filtersClass.getMembershipRequestFilters(); + + return ( + <> + {/* auto column grid used instead of SUI grid for better searchbar width adjustment */} +
+
+
+ +
+
+ +
+ +
+
+
+
+ + +
+
+
+
+ +
+ + + ); + } +} + +MembershipRequestsSearchLayout.propTypes = { + config: PropTypes.object.isRequired, + roles: PropTypes.array.isRequired, + appName: PropTypes.string, +}; + +MembershipRequestsSearchLayout.defaultProps = { + appName: "", +}; diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/index.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/index.js new file mode 100644 index 000000000..7eefec3e0 --- /dev/null +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/index.js @@ -0,0 +1,96 @@ +/* + * This file is part of Invenio. + * Copyright (C) 2022 CERN. + * Copyright (C) 2024 Northwestern University. + * + * Invenio 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 { parametrize, overrideStore } from "react-overridable"; +import { createSearchAppInit } from "@js/invenio_search_ui"; +import { DropdownSort } from "@js/invenio_search_ui/components"; +import { i18next } from "@translations/invenio_communities/i18next"; +import { RequestAcceptButton, RequestDeclineButton } from "@js/invenio_requests/components/Buttons"; +import { + RequestAcceptModalTrigger, + RequestDeclineModalTrigger, +} from "@js/invenio_requests/components/ModalTriggers"; +import { + SubmitStatus, + DeleteStatus, + AcceptStatus, + DeclineStatus, + CancelStatus, + ExpireStatus, +} from "@js/invenio_requests/request"; + +import { MembershipRequestsContextProvider as ContextProvider } from "../../api/membershipRequests/MembershipRequestsContextProvider"; +import { MemberRequestsResults } from "../MemberRequestsResults"; +import { MemberRequestsSearchBarElement } from "../MemberRequestsSearchBarElement"; +import { MembershipRequestsEmptyResults } from "./MembershipRequestsEmptyResults"; +import { MembershipRequestsResultsContainer } from "./MembershipRequestsResultsContainer"; +import { MembershipRequestsResultItem as ResultItem } from "./MembershipRequestsResultItem"; +import { MembershipRequestsSearchLayout as SearchLayout } from "./MembershipRequestsSearchLayout"; + +const dataAttr = document.getElementById( + "community-membership-requests-search-root" +).dataset; +const community = JSON.parse(dataAttr.community); +const communitiesAllRoles = JSON.parse(dataAttr.communitiesAllRoles); +const communitiesRolesCanAssign = JSON.parse(dataAttr.communitiesRolesCanAssign); +// TODO: Decision flow: do we need? +// const permissions = JSON.parse(dataAttr.permissions); + +const appName = "InvenioCommunities.MembershipRequestsSearch"; + +const MembershipRequestsContextProvider = parametrize(ContextProvider, { + community: community, +}); + +const MembershipRequestsSearchLayout = parametrize(SearchLayout, { + roles: communitiesAllRoles, + appName: appName, +}); + +const MembershipRequestsSearchBarElement = parametrize(MemberRequestsSearchBarElement, { + className: "member-requests-searchbar", + placeholder: i18next.t("Search in membership requests..."), +}); + +const MembershipRequestsResultItem = parametrize(ResultItem, { + config: { rolesCanAssign: communitiesRolesCanAssign }, + community: community, +}); + +const defaultComponents = { + [`${appName}.SearchApp.layout`]: MembershipRequestsSearchLayout, + [`${appName}.SearchBar.element`]: MembershipRequestsSearchBarElement, + [`${appName}.Sort.element`]: DropdownSort, + [`${appName}.ResultsList.container`]: MembershipRequestsResultsContainer, + [`${appName}.SearchApp.results`]: MemberRequestsResults, + [`${appName}.ResultsList.item`]: MembershipRequestsResultItem, + [`${appName}.EmptyResults.element`]: MembershipRequestsEmptyResults, + // The RequestModalTriggers are generic enough to be reused here + "RequestActionModalTrigger.accept": RequestAcceptModalTrigger, + "RequestActionModalTrigger.decline": RequestDeclineModalTrigger, + "RequestActionButton.accept": RequestAcceptButton, + "RequestActionButton.decline": RequestDeclineButton, + "RequestStatus.layout.submitted": SubmitStatus, + "RequestStatus.layout.deleted": DeleteStatus, + "RequestStatus.layout.accepted": AcceptStatus, + "RequestStatus.layout.declined": DeclineStatus, + "RequestStatus.layout.cancelled": CancelStatus, + "RequestStatus.layout.expired": ExpireStatus, +}; + +const overriddenComponents = overrideStore.getAll(); + +// Auto-initialize search app +createSearchAppInit( + { ...defaultComponents, ...overriddenComponents }, // defaultcomponents = + true, // autoInit = + "invenio-search-config", // autoInitDataAttr = + true, // multi = + MembershipRequestsContextProvider // ContainerComponent = +); diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/members/utils.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/utils.js new file mode 100644 index 000000000..f7c8167ba --- /dev/null +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/utils.js @@ -0,0 +1,40 @@ +/* + * This file is part of Invenio. + * Copyright (C) 2024 Northwestern University. + * + * Invenio 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 { i18next } from "@translations/invenio_communities/i18next"; +import { DateTime } from "luxon"; +import _cloneDeep from "lodash/cloneDeep"; +import _pick from "lodash/pick"; + +/** + * Format given `at` time relative to current time. + * + * @export + * @param {string} at + * @return {string} + */ +export function formattedTime(at) { + return DateTime.fromISO(at).setLocale(i18next.language).toRelative(); +} + +/** + * Build a "Request" object for the purposes of a controller and own sanity. + * + * @export + * @param {Object} resultItem + * @param {Array} keysOfActionsLinks + * @return {Object} + */ +export function buildRequest(resultItem, keysOfActionsLinks) { + let { request } = resultItem; + request = _cloneDeep(request); + request.links = { + actions: _pick(resultItem.links.actions, keysOfActionsLinks), + }; + return request; +} diff --git a/invenio_communities/communities/services/service.py b/invenio_communities/communities/services/service.py index a9862b1b8..69e352626 100644 --- a/invenio_communities/communities/services/service.py +++ b/invenio_communities/communities/services/service.py @@ -169,6 +169,8 @@ def search_community_requests( must=[ dsl.Q("term", **{"receiver.community": community_id}), ~dsl.Q("term", **{"status": "created"}), + # Excluding explicitly for now + ~dsl.Q("term", **{"type": "community-membership-request"}), ], ), **kwargs, diff --git a/invenio_communities/config.py b/invenio_communities/config.py index bb325fb82..e654bbc32 100644 --- a/invenio_communities/config.py +++ b/invenio_communities/config.py @@ -31,6 +31,7 @@ "invitations": "/communities//invitations", "about": "/communities//about", "curation_policy": "/communities//curation-policy", + "membership_requests": "/communities//membership-requests", } """Communities ui endpoints.""" @@ -201,6 +202,31 @@ } """Definitions of available record sort options.""" +COMMUNITIES_MEMBERSHIP_REQUESTS_SEARCH = { + "facets": ["type", "status"], + "sort": ["bestmatch", "name", "newest", "oldest"], +} +"""Community membership requests search configuration.""" + +COMMUNITIES_MEMBERSHIP_REQUESTS_SORT_OPTIONS = { + "bestmatch": dict( + title=_("Best match"), + fields=["_score"], # ES defaults to desc on `_score` field + ), + "name": dict( + title=_("Name"), + fields=["user.profile.full_name.keyword"], + ), + "newest": dict( + title=_("Newest"), + fields=["-created"], + ), + "oldest": dict( + title=_("Oldest"), + fields=["created"], + ), +} +"""Available membership requests sort options.""" COMMUNITIES_INVITATIONS_EXPIRES_IN = timedelta(days=30) """"Default amount of time before an invitation expires.""" @@ -315,5 +341,5 @@ COMMUNITIES_ALWAYS_SHOW_CREATE_LINK = False """Controls visibility of 'New Community' btn based on user's permission when set to True.""" -COMMUNITIES_ALLOW_MEMBERSHIP_REQUESTS = False +COMMUNITIES_ALLOW_MEMBERSHIP_REQUESTS = True """Feature flag for membership request.""" diff --git a/invenio_communities/ext.py b/invenio_communities/ext.py index 62911f001..b3595faac 100644 --- a/invenio_communities/ext.py +++ b/invenio_communities/ext.py @@ -67,6 +67,13 @@ def init_app(self, app): self.init_hooks(app) self.init_cache(app) + # TMP ADDITIONS + @app.context_processor + def inject_variables(): + # Get all variables in the template context + # variables = {key: value for key, value in g.items()} + return {'template_variables': app.jinja_env} + def init_config(self, app): """Initialize configuration. diff --git a/invenio_communities/members/records/api.py b/invenio_communities/members/records/api.py index 5bf3ac28b..41edcb429 100644 --- a/invenio_communities/members/records/api.py +++ b/invenio_communities/members/records/api.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2022 Northwestern University. +# Copyright (C) 2024 Northwestern University. # Copyright (C) 2022 CERN. # Copyright (C) 2022 Graz University of Technology. # @@ -19,14 +19,16 @@ from invenio_records_resources.records.systemfields import IndexField from invenio_requests.records.api import Request from invenio_users_resources.records.api import GroupAggregate, UserAggregate -from sqlalchemy import or_ +from sqlalchemy import or_, select from ..errors import InvalidMemberError +from .dumpers import RequestTypeDumperExt from .models import ArchivedInvitationModel, MemberModel relations_dumper = SearchDumper( extensions=[ RelationDumperExt("relations"), + RequestTypeDumperExt(), IndexedAtDumperExt(), ] ) @@ -43,10 +45,10 @@ class MemberMixin: """The data-layer id of the user (or None).""" group_id = ModelField("group_id") - """The data-layer id of the user (or None).""" + """The data-layer id of the group (or None).""" request_id = ModelField("request_id") - """The data-layer id of the user (or None).""" + """The data-layer id of the request (or None).""" role = ModelField("role") """The role of the entity.""" @@ -86,7 +88,7 @@ class MemberMixin: Request, "request_id", "request", - attrs=["status", "expires_at", "is_open"], + attrs=["status", "expires_at", "is_open", "type"], ), ) @@ -160,6 +162,21 @@ def has_members(cls, community_id, role=None): """Get members of a community.""" return cls.model_cls.count_members(community_id, role=role) + @classmethod + def get_pending_request_id_if_any(cls, user_id, community_id): + """Return request id of membership request/invitation still pending. + + Return type UUID. + """ + stmt = ( + select(cls.model_cls.request_id) + .where(cls.model_cls.user_id == user_id) + .where(cls.model_cls.community_id == community_id) + .where(cls.model_cls.active == False) + ) + request_id = db.session.scalars(stmt).one_or_none() + return request_id + class Member(Record, MemberMixin): """A member/invitation record. diff --git a/invenio_communities/members/records/dumpers.py b/invenio_communities/members/records/dumpers.py new file mode 100644 index 000000000..5d797e4b2 --- /dev/null +++ b/invenio_communities/members/records/dumpers.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# +# 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. + +"""Secondary storage (ES/OS) dumpers.""" + +from invenio_records.dictutils import dict_lookup, dict_set +from invenio_records.dumpers.search import SearchDumperExt + + +class RequestTypeDumperExt(SearchDumperExt): + """Dumper for the relations.request.type field.""" + + def __init__(self): + """Initialize the dumper.""" + self.key = "relations.request.type" + + def dump(self, record, data): + """Dump relations.""" + try: # In case no associated request type + request_type = dict_lookup(record, "request.type") + # Serialize back RequestType to its identifier only + dict_set(data, "request.type", request_type.type_id) + except KeyError: + return + + def load(self, data, record_cls): + """Load relations.request.type. + + TODO: Works without it for now. Potentially revisit? + """ + pass diff --git a/invenio_communities/members/records/mappings/os-v1/communitymembers/archivedinvitations/archivedinvitation-v1.0.0.json b/invenio_communities/members/records/mappings/os-v1/communitymembers/archivedinvitations/archivedinvitation-v1.0.0.json index f261cb7e0..5cf71d1eb 100644 --- a/invenio_communities/members/records/mappings/os-v1/communitymembers/archivedinvitations/archivedinvitation-v1.0.0.json +++ b/invenio_communities/members/records/mappings/os-v1/communitymembers/archivedinvitations/archivedinvitation-v1.0.0.json @@ -146,6 +146,9 @@ }, "is_open": { "type": "boolean" + }, + "type": { + "type": "keyword" } } }, diff --git a/invenio_communities/members/records/mappings/os-v1/communitymembers/members/member-v1.0.0.json b/invenio_communities/members/records/mappings/os-v1/communitymembers/members/member-v1.0.0.json index f261cb7e0..5cf71d1eb 100644 --- a/invenio_communities/members/records/mappings/os-v1/communitymembers/members/member-v1.0.0.json +++ b/invenio_communities/members/records/mappings/os-v1/communitymembers/members/member-v1.0.0.json @@ -146,6 +146,9 @@ }, "is_open": { "type": "boolean" + }, + "type": { + "type": "keyword" } } }, diff --git a/invenio_communities/members/records/mappings/os-v2/communitymembers/archivedinvitations/archivedinvitation-v1.0.0.json b/invenio_communities/members/records/mappings/os-v2/communitymembers/archivedinvitations/archivedinvitation-v1.0.0.json index f261cb7e0..5cf71d1eb 100644 --- a/invenio_communities/members/records/mappings/os-v2/communitymembers/archivedinvitations/archivedinvitation-v1.0.0.json +++ b/invenio_communities/members/records/mappings/os-v2/communitymembers/archivedinvitations/archivedinvitation-v1.0.0.json @@ -146,6 +146,9 @@ }, "is_open": { "type": "boolean" + }, + "type": { + "type": "keyword" } } }, diff --git a/invenio_communities/members/records/mappings/os-v2/communitymembers/members/member-v1.0.0.json b/invenio_communities/members/records/mappings/os-v2/communitymembers/members/member-v1.0.0.json index f261cb7e0..5cf71d1eb 100644 --- a/invenio_communities/members/records/mappings/os-v2/communitymembers/members/member-v1.0.0.json +++ b/invenio_communities/members/records/mappings/os-v2/communitymembers/members/member-v1.0.0.json @@ -146,6 +146,9 @@ }, "is_open": { "type": "boolean" + }, + "type": { + "type": "keyword" } } }, diff --git a/invenio_communities/members/records/mappings/v7/communitymembers/archivedinvitations/archivedinvitation-v1.0.0.json b/invenio_communities/members/records/mappings/v7/communitymembers/archivedinvitations/archivedinvitation-v1.0.0.json index f261cb7e0..5cf71d1eb 100644 --- a/invenio_communities/members/records/mappings/v7/communitymembers/archivedinvitations/archivedinvitation-v1.0.0.json +++ b/invenio_communities/members/records/mappings/v7/communitymembers/archivedinvitations/archivedinvitation-v1.0.0.json @@ -146,6 +146,9 @@ }, "is_open": { "type": "boolean" + }, + "type": { + "type": "keyword" } } }, diff --git a/invenio_communities/members/records/mappings/v7/communitymembers/members/member-v1.0.0.json b/invenio_communities/members/records/mappings/v7/communitymembers/members/member-v1.0.0.json index f261cb7e0..5cf71d1eb 100644 --- a/invenio_communities/members/records/mappings/v7/communitymembers/members/member-v1.0.0.json +++ b/invenio_communities/members/records/mappings/v7/communitymembers/members/member-v1.0.0.json @@ -146,6 +146,9 @@ }, "is_open": { "type": "boolean" + }, + "type": { + "type": "keyword" } } }, diff --git a/invenio_communities/members/resources/resource.py b/invenio_communities/members/resources/resource.py index d65828887..f4e342d92 100644 --- a/invenio_communities/members/resources/resource.py +++ b/invenio_communities/members/resources/resource.py @@ -36,6 +36,9 @@ def create_url_rules(self): route("PUT", routes["invitations"], self.update_invitations), route("GET", routes["invitations"], self.search_invitations), route("POST", routes["membership_requests"], self.request_membership), + route( + "GET", routes["membership_requests"], self.search_membership_requests + ), ] @request_view_args @@ -144,3 +147,16 @@ def delete(self): data=resource_requestctx.data, ) return "", 204 + + @request_view_args + @request_search_args + @response_handler(many=True) + def search_membership_requests(self): + """Perform a search over the membership requests.""" + hits = self.service.search_membership_requests( + g.identity, + resource_requestctx.view_args["pid_value"], + params=resource_requestctx.args, + search_preference=search_preference(), + ) + return hits.to_dict(), 200 diff --git a/invenio_communities/members/services/config.py b/invenio_communities/members/services/config.py index 35a255c56..e7d573b0e 100644 --- a/invenio_communities/members/services/config.py +++ b/invenio_communities/members/services/config.py @@ -28,6 +28,7 @@ from ..records.api import ArchivedInvitation from . import facets from .components import CommunityMemberCachingComponent +from .links import LinksForActionsOfMember, LinksForRequestActionsOfMember from .schemas import MemberEntitySchema @@ -182,11 +183,20 @@ class MemberServiceConfig(RecordServiceConfig, ConfiguratorMixin): search_public = PublicSearchOptions search_invitations = InvitationsSearchOptions - # No links - links_item = {} + links_item = { + "actions": LinksForActionsOfMember( + [ + LinksForRequestActionsOfMember( + "{+api}/requests/{request_id}/actions/{action}" + ), + ] + ) + } # ResultList configurations - links_search = pagination_links("{+api}/communities/{community_id}/members{?args*}") + links_search = pagination_links( + "{+api}/communities/{community_id}/{endpoint}{?args*}" + ) # Service components components = [ diff --git a/invenio_communities/members/services/links.py b/invenio_communities/members/services/links.py new file mode 100644 index 000000000..67b3b0f39 --- /dev/null +++ b/invenio_communities/members/services/links.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- +# +# 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. + +"""Links generation for the members service.""" + +from invenio_records_resources.services.base.links import Link, LinksTemplate +from invenio_requests.customizations import RequestActions +from invenio_requests.proxies import current_requests_service +from invenio_requests.resolvers.registry import ResolverRegistry +from uritemplate import URITemplate + + +class MemberLinksTemplate(LinksTemplate): + """A links template that passes the request type in the context. + + This template is useful to avoid having to dereference the request type into + members at the DB-level. It's legitimate (for now), because we know what + kind of request type we are dealing with at the service search method (e.g. when + searching for invitations we know the request type of requests associated with + Members is CommunityInvitation). + """ + + def __init__(self, links, context=None, request_type=None): + """Constructor. + + :param links: a dict of Links (or objects that have same interface) + :param context: dict of context values + :param request_type: a RequestType + """ + context = context or {} + context["request_type"] = request_type + super().__init__(links, context=context) + + +class LinksForActionsOfMember: + """Intermediary template of links. + + It responds to the same interface as a `Link`, but is used to dynamically generate + the dict of different possible action links of a Member. + + This is part of allowing us to save on extra attributes on the config and condensing + link generation where it belongs to a narrow interface with deep logic. + """ + + def __init__(self, links_for_actions): + """Constructor. + + :param links_for_actions: list of Link-like + """ + self._links_for_actions = links_for_actions + + def expand(self, obj, context): + """Expand all the action link templates. + + :param obj: api.Member + :param context: dict of contextual values + + :return: dict of links + """ + links = {} + for link in self._links_for_actions: + if link.should_render(obj, context): + link.expand(obj, context, into=links) + return links + + def should_render(self, obj, context): + """Conforms to `Link` interface but always renders. + + Consequence: will always render the key even if no action links should render + i.e. if empty dict. This is probably simpler for frontend too. + """ + return True + + +class RequestLike: + """A Request like object for interface purposes.""" + + def __init__(self, obj, context): + """Constructor. + + May raise IndexError (and that's Ok - should be handled). + + :param obj: api.Member + :param context: dict of context values + """ + self.id = obj.request_id + self.type = context["request_type"] + request_relation = obj["request"] + self.status = request_relation["status"] + self.created_by = self._get_created_by(obj) + self.receiver = self._get_receiver(obj) + + def _get_created_by(self, obj): + """Get the created_by field's proxy. + + Assigns a Proxy to `created_by` based on the type of request + associated with obj. + + Warning: constructor method: not full self yet. + + :param obj: api.Member + """ + # This assertion is to alert us developers if the associated + # ref_types certainty of only 1 type changes. If it does, we need to rethink + # things. + assert 1 == len(self.type.allowed_creator_ref_types) + + creator_ref_type = self.type.allowed_creator_ref_types[0] + return self._get_proxy_by_ref_type(creator_ref_type, obj) + + def _get_receiver(self, obj): + """Set the receiver field. + + Assigns a Proxy to `receiver` based on the type of request associated with obj. + + Warning: constructor method: not full self yet. + + :param obj: api.Member + """ + # This assertion is to alert us developers if the associated + # ref_types certainty of only 1 type changes. If it does, we need to rethink + # things. + assert 1 == len(self.type.allowed_receiver_ref_types) + + receiver_ref_type = self.type.allowed_receiver_ref_types[0] + return self._get_proxy_by_ref_type(receiver_ref_type, obj) + + # assert 1 == len(self.type.allowed_topic_ref_types) + + def _get_proxy_by_ref_type(self, ref_type, obj): + """Returns proxy for given ref type. + + :param ref_type: string key of reference type + :param obj: api.Member + """ + if ref_type == "community": + # This *creates* an entity proxy contrary to the name + return ResolverRegistry.resolve_entity_proxy( + {"community": obj.community_id} + ) + elif ref_type == "user": + # This *creates* an entity proxy contrary to the name + return ResolverRegistry.resolve_entity_proxy({"user": obj.user_id}) + else: + # again mostly for developers to be alerted + raise Exception("ref_type is unknown!") + + +class LinksForRequestActionsOfMember: + """Links specifically for the request related to the Member.""" + + def __init__(self, uritemplate): + """Constructor.""" + # Only accepting the uritemplate as arg for "consistency" with other Link-likes + # so that URLs can be perused on skimming a service's config. + self._uritemplate = uritemplate + + def should_render(self, obj, context): + """Render based on if there is an associated request at all. + + :param obj: api.Member + :param context: dict of context values + """ + try: + RequestLike(obj, context) + return True + except KeyError: + return False + + def expand(self, obj, context, into): + """Expand all the member's request's action links templates. + + :param obj: api.Member + :param context: dict of context values + :param into: dict of resulting links + """ + # Know that we can get a RequestLike without issue at this point since + # should_render has returned True. + request_like = RequestLike(obj, context) + + for action in request_like.type.available_actions: + link = LinkForRequestAction(self._uritemplate, action) + if link.should_render(request_like, context): + into[action] = link.expand(request_like, context) + + +class LinkForRequestAction(Link): + """Link for the action of a request.""" + + def __init__(self, uritemplate, action): + """Constructor.""" + self._uritemplate = URITemplate(uritemplate) + self.action = action + + def _vars_func(self, request, vars): + """Inject the passed vars (context) with items specific to this Link. + + `vars` has been copied at this point and therefore can be + modified in-place. + + :param request: RequestLike + :param vars: dict of contextual values + """ + vars.update({"action": self.action, "request_id": request.id}) + + def should_render(self, request, context): + """Determine if the link should render.""" + action_for_execute = self.action + action_for_permission = f"action_{action_for_execute}" + identity = context.get("identity") + permission = current_requests_service.permission_policy( + action_for_permission, + request=request, + ) + # fmt: off + return ( + RequestActions.can_execute(request, action_for_execute) + and permission.allows(identity) + ) + # fmt: on diff --git a/invenio_communities/members/services/request.py b/invenio_communities/members/services/request.py index 2dba594e2..2eac4ef56 100644 --- a/invenio_communities/members/services/request.py +++ b/invenio_communities/members/services/request.py @@ -28,6 +28,7 @@ def service(): """Service.""" return current_communities.service.members + # # CommunityInvitation: actions and request type # @@ -141,10 +142,28 @@ class CancelMembershipRequestAction(actions.CancelAction): def execute(self, identity, uow): """Execute action.""" service().close_membership_request(system_identity, self.request.id, uow=uow) - # TODO: Investigate notifications + # TODO: Notification flow: Investigate notifications super().execute(identity, uow) +class AcceptMembershipRequestAction(actions.AcceptAction): + """Accept membership request action.""" + + def execute(self, identity, uow): + """Execute action.""" + # TODO: Decision flow: Implement me + pass + + +class DeclineMembershipRequestAction(actions.DeclineAction): + """Decline membership request action.""" + + def execute(self, identity, uow): + """Execute action.""" + # TODO: Decision flow: Implement me + pass + + class MembershipRequestRequestType(RequestType): """Request type for membership requests.""" @@ -155,6 +174,8 @@ class MembershipRequestRequestType(RequestType): available_actions = { "create": actions.CreateAndSubmitAction, "cancel": CancelMembershipRequestAction, + "accept": AcceptMembershipRequestAction, + "decline": DeclineMembershipRequestAction, } creator_can_be_none = False @@ -162,3 +183,14 @@ class MembershipRequestRequestType(RequestType): allowed_creator_ref_types = ["user"] allowed_receiver_ref_types = ["community"] allowed_topic_ref_types = ["community"] + + # This indicates what roles an identity must have within the receiving community + # in order to accept/decline. Although a pattern, it's ultimately a more hidden way + # to define permission than a permission policy. It repeats concept because it + # is subservient to the Request permission policy abstractions. + needs_context = { + "community_roles": [ + "owner", + "manager", + ] + } diff --git a/invenio_communities/members/services/schemas.py b/invenio_communities/members/services/schemas.py index ad9f9f71f..471018072 100644 --- a/invenio_communities/members/services/schemas.py +++ b/invenio_communities/members/services/schemas.py @@ -58,6 +58,7 @@ class RequestSchema(Schema): # because the relations field doesn't properly load data from the index # (it should have converted expires_at into a datetime object). expires_at = fields.String() + type = fields.String() # @@ -225,3 +226,12 @@ def get_permissions(self, obj): member=obj, ), } + + +class MembershipRequestDumpSchema(MemberDumpSchema): + """Schema for dumping membership requests. + + TODO: Decision flow: Investigate if can be merged with InvitationDumpSchema + """ + + request = fields.Nested(RequestSchema) diff --git a/invenio_communities/members/services/service.py b/invenio_communities/members/services/service.py index 24255467b..a03a19891 100644 --- a/invenio_communities/members/services/service.py +++ b/invenio_communities/members/services/service.py @@ -13,7 +13,6 @@ from flask import current_app from invenio_access.permissions import system_identity -from invenio_accounts.models import Role from invenio_i18n import gettext as _ from invenio_notifications.services.uow import NotificationOp from invenio_records_resources.services import LinksTemplate @@ -33,13 +32,13 @@ from kombu import Queue from marshmallow import ValidationError from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm.exc import NoResultFound from werkzeug.local import LocalProxy from ...notifications.builders import CommunityInvitationSubmittedNotificationBuilder from ...proxies import current_roles from ..errors import AlreadyMemberError, InvalidMemberError from ..records.api import ArchivedInvitation +from .links import MemberLinksTemplate from .request import CommunityInvitation, MembershipRequestRequestType from .schemas import ( AddBulkSchema, @@ -47,6 +46,7 @@ InvitationDumpSchema, InviteBulkSchema, MemberDumpSchema, + MembershipRequestDumpSchema, PublicDumpSchema, RequestMembershipSchema, UpdateBulkSchema, @@ -69,6 +69,8 @@ def community_cls(self): """Return community class.""" return self.config.community_cls + # Dumping schemas + @property def member_dump_schema(self): """Schema for creation.""" @@ -84,6 +86,13 @@ def invitation_dump_schema(self): """Schema for creation.""" return ServiceSchemaWrapper(self, schema=InvitationDumpSchema) + @property + def membership_request_dump_schema(self): + """Schema for dumping a membership request to JSON.""" + return ServiceSchemaWrapper(self, schema=MembershipRequestDumpSchema) + + # Loading schemas + @property def add_schema(self): """Schema for creation.""" @@ -106,7 +115,7 @@ def delete_schema(self): @property def request_membership_schema(self): - """Wrapped schema for request membership.""" + """Wrapped load schema for a membership request payload.""" return ServiceSchemaWrapper(self, schema=RequestMembershipSchema) @property @@ -293,7 +302,7 @@ def _invite_factory(self, identity, community, role, visible, member, message, u "description": description, }, CommunityInvitation, - {"user": member["id"]}, + receiver={"user": member["id"]}, creator=community, # TODO: perhaps topic should be the actual membership record # instead @@ -362,6 +371,8 @@ def search( extra_filter=filter_, params=params, search_preference=search_preference, + endpoint="members", + links_item_tpl=LinksTemplate(self.config.links_item), **kwargs ) @@ -390,6 +401,9 @@ def scan( scan_params=params, search_preference=search_preference, scan=True, + # just in case scan becomes accessible through a resource URL + endpoint="members", + links_item_tpl=LinksTemplate(self.config.links_item), **kwargs ) @@ -412,6 +426,8 @@ def search_public( ), params=params, search_preference=search_preference, + endpoint="members", + links_item_tpl=LinksTemplate(self.config.links_item), **kwargs ) @@ -429,9 +445,16 @@ def search_invitations( self.invitation_dump_schema, self.config.search_invitations, record_cls=ArchivedInvitation, - extra_filter=dsl.Q("term", **{"active": False}), + extra_filter=( + dsl.Q("term", **{"active": False}) + & dsl.Q("term", **{"request.type": CommunityInvitation.type_id}) + ), params=params, search_preference=search_preference, + endpoint="invitations", + links_item_tpl=MemberLinksTemplate( + self.config.links_item, request_type=CommunityInvitation + ), **kwargs ) @@ -447,6 +470,8 @@ def _members_search( search_preference=None, scan=False, scan_params=None, + endpoint="members", + links_item_tpl=None, **kwargs ): """Members search.""" @@ -460,7 +485,6 @@ def _members_search( # Prepare and execute the search params = params or {} - scan_params = scan_params or {} search = self._search( "search_members", @@ -472,28 +496,32 @@ def _members_search( extra_filter=filter, **kwargs ) - # scan has a default scroll timeout of 5 minutes - # https://github.com/opensearch-project/opensearch-py/blob/fe3b5a8922aa8eb04f735c74d127d7ea68a00bec/opensearchpy/helpers/actions.py#L492-L503 - search_result = ( - search.params(**scan_params).scan() if scan else search.execute() - ) + + if scan: + scan_params = scan_params or {} + # scan has a default scroll timeout of 5 minutes + # https://github.com/opensearch-project/opensearch-py/blob/fe3b5a8922aa8eb04f735c74d127d7ea68a00bec/opensearchpy/helpers/actions.py#L492-L503 + search_result = search.params(**scan_params).scan() + links_tpl = None + links_item_tpl = None + else: + search_result = search.execute() + links_tpl = LinksTemplate( + self.config.links_search, + context={ + "args": params, + "community_id": community_id, + "endpoint": endpoint, + }, + ) return self.result_list( self, identity, search_result, params, - links_tpl=( - None - if scan - else LinksTemplate( - self.config.links_search, - context={ - "args": params, - "community_id": community_id, - }, - ) - ), + links_tpl=links_tpl, + links_item_tpl=links_item_tpl, schema=schema, ) @@ -778,7 +806,7 @@ def request_membership(self, identity, community_id, data, uow=None): receiver=community, creator={"user": str(identity.user.id)}, topic=community, # user instead? - # TODO: Consider expiration + # TODO: Expiration flow: Consider expiration # expires_at=invite_expires_at(), uow=uow, ) @@ -794,7 +822,7 @@ def request_membership(self, identity, community_id, data, uow=None): notify=False, ) - # TODO: Add notification mechanism + # TODO: Notification flow: Add notification mechanism # uow.register( # NotificationOp( # MembershipRequestSubmittedNotificationBuilder.build( @@ -831,10 +859,32 @@ def update_membership_request(self, identity, community_id, data, uow=None): # TODO: Implement me pass - def search_membership_requests(self): + def search_membership_requests( + self, identity, community_id, params=None, search_preference=None, **kwargs + ): """Search membership requests.""" - # TODO: Implement me - pass + return self._members_search( + identity, + community_id, + "search_membership_requests", + self.membership_request_dump_schema, + # Use same as invitations + self.config.search_invitations, # TODO: Decision flow: Rename/merge ? + record_cls=ArchivedInvitation, # TODO: Decision flow: merge or new? + extra_filter=( + dsl.Q("term", **{"active": False}) + & dsl.Q( + "term", **{"request.type": MembershipRequestRequestType.type_id} + ) + ), + params=params, + search_preference=search_preference, + endpoint="membership-requests", + links_item_tpl=MemberLinksTemplate( + self.config.links_item, request_type=MembershipRequestRequestType + ), + **kwargs + ) @unit_of_work() def accept_membership_request(self, identity, request_id, uow=None): @@ -857,3 +907,10 @@ def close_membership_request(self, identity, request_id, uow=None): member = self.record_cls.get_member_by_request(request_id) assert member.active is False uow.register(RecordDeleteOp(member, indexer=self.indexer, force=True)) + + def get_pending_request_id_if_any(self, user_id, community_id): + """Utility function to get associated active request id. + + Only pending members fit this. In other cases return None. + """ + return self.record_cls.get_pending_request_id_if_any(user_id, community_id) diff --git a/invenio_communities/permissions.py b/invenio_communities/permissions.py index b67d7f3b4..a631398ea 100644 --- a/invenio_communities/permissions.py +++ b/invenio_communities/permissions.py @@ -199,6 +199,7 @@ class CommunityPermissionPolicy(BasePermissionPolicy): else_=[Disable()], ), ] + can_search_membership_requests = [CommunityManagers(), SystemProcess()] def can_perform_action(community, context): diff --git a/invenio_communities/searchapp.py b/invenio_communities/searchapp.py index ad1b9a11a..24f19db82 100644 --- a/invenio_communities/searchapp.py +++ b/invenio_communities/searchapp.py @@ -53,4 +53,14 @@ def search_app_context(): initial_filters=[["is_open", "true"]], endpoint="/api/requests", ), + "search_app_communities_membership_requests_config": partial( + search_app_config, + config_name="COMMUNITIES_MEMBERSHIP_REQUESTS_SEARCH", + available_facets=current_app.config["REQUESTS_FACETS"], + sort_options=( + current_app.config["COMMUNITIES_MEMBERSHIP_REQUESTS_SORT_OPTIONS"] + ), + headers={"Accept": "application/json"}, + initial_filters=[["is_open", "true"]], + ), } diff --git a/invenio_communities/templates/semantic-ui/invenio_communities/details/header.html b/invenio_communities/templates/semantic-ui/invenio_communities/details/header.html index 0f5f9ebbf..57c514961 100644 --- a/invenio_communities/templates/semantic-ui/invenio_communities/details/header.html +++ b/invenio_communities/templates/semantic-ui/invenio_communities/details/header.html @@ -11,15 +11,23 @@ {%- from "invenio_theme/macros/truncate.html" import truncate_text %} {%- from "invenio_communities/details/macros/access-status-label.html" import access_status_label -%} -{% macro button_to_request_membership(community) %} - {% if permissions.can_request_membership %} - {# TODO: Add relation_to_community for other flows #} -
+ + {{ _("Membership discussion") }} + + {% elif permissions.can_request_membership %} +
-
+
+ {% else %} + {# show nothing #} {% endif %} {% endmacro %} @@ -120,7 +128,7 @@

{{ community.metadata.title }}

- {{ button_to_request_membership(community) }} + {{ button_for_membership(community) }} {%- if not community_use_jinja_header %} diff --git a/invenio_communities/templates/semantic-ui/invenio_communities/details/members/base.html b/invenio_communities/templates/semantic-ui/invenio_communities/details/members/base.html index 1af3c3ef6..157af4f5e 100644 --- a/invenio_communities/templates/semantic-ui/invenio_communities/details/members/base.html +++ b/invenio_communities/templates/semantic-ui/invenio_communities/details/members/base.html @@ -18,6 +18,7 @@ {%- set menu_items = { 'members': (_('Members'), url_for('invenio_communities.members', pid_value=community.slug), permissions.can_read), 'invitations': (_('Invitations'), url_for('invenio_communities.invitations', pid_value=community.slug), permissions.can_search_invites), + 'membership_requests': (_('Membership Requests'), url_for('invenio_communities.membership_requests', pid_value=community.slug), permissions.can_search_membership_requests), } %}