Skip to content

Commit

Permalink
js+service: [inveniosoftware#855] 4) integrate request flow with fron…
Browse files Browse the repository at this point in the history
…tend [+]

This concludes the 2nd flow of the membership request feature.
Remaining flows are
- 'waiting for decision' flow
- 'making a decision' flow

This PR needs to be complemented by:
- one in invenio-requests (done)
- one in invenio-rdm-records (to do)
  • Loading branch information
fenekku committed May 7, 2024
1 parent 66eeb8e commit be11589
Show file tree
Hide file tree
Showing 10 changed files with 253 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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/");
}
}
Original file line number Diff line number Diff line change
@@ -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);
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Formik
initialValues={{
requestMembershipComment: "",
message: "",
}}
onSubmit={onSubmit}
>
Expand All @@ -42,9 +68,17 @@ export function RequestMembershipModal(props) {
>
<Modal.Header>{i18next.t("Request Membership")}</Modal.Header>
<Modal.Content>
<Message hidden={errorMsg === ""} negative className="flashed">
<Grid container>
<Grid.Column mobile={16} tablet={12} computer={8} textAlign="left">
<strong>{errorMsg}</strong>
</Grid.Column>
</Grid>
</Message>

<Form>
<TextAreaField
fieldPath="requestMembershipComment"
fieldPath="message"
label={i18next.t("Message to managers (optional)")}
/>
</Form>
Expand All @@ -54,12 +88,12 @@ export function RequestMembershipModal(props) {
{i18next.t("Cancel")}
</Button>
<Button
onClick={(event) => {
// TODO: Implement me
console.log("RequestMembershipModal button clicked.");
}}
positive={confirmed}
disabled={isSubmitting}
loading={isSubmitting}
onClick={handleSubmit}
positive
primary
type="button"
>
{i18next.t("Request Membership")}
</Button>
Expand All @@ -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);
Expand All @@ -97,8 +133,16 @@ export function RequestMembershipButton(props) {
content={i18next.t("Request Membership")}
/>
{isModalOpen && (
<RequestMembershipModal isOpen={isModalOpen} onClose={handleClose} />
<RequestMembershipModal
isOpen={isModalOpen}
onClose={handleClose}
community={community}
/>
)}
</>
);
}

RequestMembershipButton.propTypes = {
community: PropTypes.object.isRequired,
};
1 change: 1 addition & 0 deletions invenio_communities/communities/services/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
20 changes: 20 additions & 0 deletions invenio_communities/members/services/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ def service():
"""Service."""
return current_communities.service.members

#
# CommunityInvitation: actions and request type
#


#
# Actions
Expand Down Expand Up @@ -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."""

Expand All @@ -135,6 +154,7 @@ class MembershipRequestRequestType(RequestType):
create_action = "create"
available_actions = {
"create": actions.CreateAndSubmitAction,
"cancel": CancelMembershipRequestAction,
}

creator_can_be_none = False
Expand Down
18 changes: 14 additions & 4 deletions invenio_communities/members/services/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
53 changes: 53 additions & 0 deletions tests/members/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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="[email protected]",
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,
)
7 changes: 0 additions & 7 deletions tests/members/test_members_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
43 changes: 43 additions & 0 deletions tests/members/test_members_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down

0 comments on commit be11589

Please sign in to comment.