diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/api/CommunityLinksExtractor.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/api/CommunityLinksExtractor.js index c831f9f77..67320a86f 100644 --- a/invenio_communities/assets/semantic-ui/js/invenio_communities/api/CommunityLinksExtractor.js +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/api/CommunityLinksExtractor.js @@ -35,4 +35,12 @@ export class CommunityLinksExtractor { } return this.#urls.invitations; } + + url(key) { + const urlOfKey = this.#urls[key]; + if (!urlOfKey) { + throw TypeError(`"${key}" link missing from resource.`); + } + return urlOfKey; + } } diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/api/RequestLinksExtractor.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/api/RequestLinksExtractor.js new file mode 100644 index 000000000..21267ad39 --- /dev/null +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/api/RequestLinksExtractor.js @@ -0,0 +1,29 @@ +// This file is part of Invenio-communities +// Copyright (C) 2024 Northwestern University. +// +// Invenio-communities is free software; you can redistribute it and/or modify it +// under the terms of the MIT License; see LICENSE file for more details. + +export class RequestLinksExtractor { + #urls; + + constructor(request) { + if (!request?.links) { + throw TypeError("Request resource links are undefined"); + } + this.#urls = request.links; + } + + url(key) { + const urlOfKey = this.#urls[key]; + if (!urlOfKey) { + throw TypeError(`"${key}" link missing from resource.`); + } + return urlOfKey; + } + + get userDiscussionUrl() { + const result = this.url("self_html"); + return result.replace("/requests/", "/me/requests/"); + } +} diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/api/membershipRequests/api.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/api/membershipRequests/api.js new file mode 100644 index 000000000..c9954d29f --- /dev/null +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/api/membershipRequests/api.js @@ -0,0 +1,26 @@ +// This file is part of Invenio-communities +// Copyright (C) 2022 CERN. +// Copyright (C) 2024 Northwestern University. +// +// Invenio-communities is free software; you can redistribute it and/or modify it +// under the terms of the MIT License; see LICENSE file for more details. + +import { CommunityLinksExtractor } from "../CommunityLinksExtractor"; +import { http } from "react-invenio-forms"; + +/** + * API Client for community membership requests. + * + * It mostly uses the API links passed to it from initial community. + * + */ +export class CommunityMembershipRequestsApi { + constructor(community) { + this.community = community; + this.linksExtractor = new CommunityLinksExtractor(community); + } + + requestMembership = async (payload) => { + return await http.post(this.linksExtractor.url("membership_requests"), payload); + }; +} diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/community/header/RequestMembershipButton.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/community/header/RequestMembershipButton.js index 42c85e39f..d09c5c8f2 100644 --- a/invenio_communities/assets/semantic-ui/js/invenio_communities/community/header/RequestMembershipButton.js +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/community/header/RequestMembershipButton.js @@ -12,23 +12,49 @@ import { Formik } from "formik"; import PropTypes from "prop-types"; import React, { useState } from "react"; import { TextAreaField } from "react-invenio-forms"; -import { Button, Form, Modal } from "semantic-ui-react"; +import { Button, Form, Grid, Message, Modal } from "semantic-ui-react"; + +import { CommunityMembershipRequestsApi } from "../../api/membershipRequests/api"; +import { communityErrorSerializer } from "../../api/serializers"; +import { RequestLinksExtractor } from "../../api/RequestLinksExtractor"; export function RequestMembershipModal(props) { - const { isOpen, onClose } = props; + const [errorMsg, setErrorMsg] = useState(""); + + const { community, isOpen, onClose } = props; const onSubmit = async (values, { setSubmitting, setFieldError }) => { - // TODO: implement me - console.log("RequestMembershipModal.onSubmit(args) called"); - console.log("TODO: implement me", arguments); - }; + /**Submit callback called from Formik. */ + setSubmitting(true); + + const client = new CommunityMembershipRequestsApi(community); + + try { + const response = await client.requestMembership(values); + const linksExtractor = new RequestLinksExtractor(response.data); + window.location.href = linksExtractor.userDiscussionUrl; + } catch (error) { + setSubmitting(false); + + console.log("Error"); + console.dir(error); - let confirmed = true; + const { errors, message } = communityErrorSerializer(error); + + if (message) { + setErrorMsg(message); + } + + if (errors) { + errors.forEach(({ field, messages }) => setFieldError(field, messages[0])); + } + } + }; return ( @@ -42,9 +68,17 @@ export function RequestMembershipModal(props) { > {i18next.t("Request Membership")} + +
@@ -54,12 +88,12 @@ export function RequestMembershipModal(props) { {i18next.t("Cancel")} @@ -73,10 +107,12 @@ export function RequestMembershipModal(props) { RequestMembershipModal.propTypes = { isOpen: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, + community: PropTypes.object.isRequired, }; export function RequestMembershipButton(props) { const [isModalOpen, setModalOpen] = useState(false); + const { community } = props; const handleClick = () => { setModalOpen(true); @@ -97,8 +133,16 @@ export function RequestMembershipButton(props) { content={i18next.t("Request Membership")} /> {isModalOpen && ( - + )} ); } + +RequestMembershipButton.propTypes = { + community: PropTypes.object.isRequired, +}; diff --git a/invenio_communities/communities/services/config.py b/invenio_communities/communities/services/config.py index 34cd0c807..4990d3c63 100644 --- a/invenio_communities/communities/services/config.py +++ b/invenio_communities/communities/services/config.py @@ -114,6 +114,7 @@ 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"), # noqa } action_link = CommunityLink( diff --git a/invenio_communities/members/services/request.py b/invenio_communities/members/services/request.py index ddc7d181b..2dba594e2 100644 --- a/invenio_communities/members/services/request.py +++ b/invenio_communities/members/services/request.py @@ -28,6 +28,10 @@ def service(): """Service.""" return current_communities.service.members +# +# CommunityInvitation: actions and request type +# + # # Actions @@ -126,6 +130,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 +154,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/tests/members/conftest.py b/tests/members/conftest.py index dcb7d69cf..102c98a2c 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,55 @@ def invite_request_id(requests_service, invite_user): type="community-invitation", ).to_dict() return res["hits"]["hits"][0]["id"] + + +# The `new_user`` module fixture leaks identity across tests, so a pure new user for +# each following test is the way to go. +@pytest.fixture() +def create_user(UserFixture, app, db, search_clear): + """Create user factory fixture.""" + + def _create_user(data=None): + """Create user.""" + data = data or {} + default_data = dict( + email="user@example.org", + password="user", + username="user", + user_profile={ + "full_name": "Created User", + "affiliations": "CERN", + }, + preferences={ + "visibility": "public", + "email_visibility": "restricted", + "notifications": { + "enabled": True, + }, + }, + active=True, + confirmed=True, + ) + actual_data = dict(default_data, **data) + u = UserFixture(**actual_data) + u.create(app, db) + current_users_service.indexer.process_bulk_queue() + current_users_service.record_cls.index.refresh() + db.session.commit() + return u + + return _create_user + + +@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_resource.py b/tests/members/test_members_resource.py index ea75a911c..897b89160 100644 --- a/tests/members/test_members_resource.py +++ b/tests/members/test_members_resource.py @@ -135,6 +135,7 @@ def test_invite_deny(client, headers, community_id, new_user, new_user_data, db) # # Update # +def test_update(client, headers, community_id, owner, public_reader, db): def test_update(client, headers, community_id, owner, public_reader, db): """Test update of members.""" client = owner.login(client) @@ -417,14 +418,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..7f66c0e89 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 #