From 863af9c88cc295ac26c4abbc593986ffaf85cbc1 Mon Sep 17 00:00:00 2001 From: Guillaume Viger Date: Wed, 3 Jul 2024 16:41:08 -0400 Subject: [PATCH] members: [#855] 5) implement 'wait for decision' flow in Members tab[+] - Most significant contribution here is the proper link(s) serialization for Members. This affects invitations and membership requests. --- .../MembershipRequestsContextProvider.js | 33 +++ .../js/invenio_communities/members/Filters.js | 6 + .../members/MemberRequestsResults.js | 58 +++++ .../members/MemberRequestsSearchBarElement.js | 59 +++++ .../MembershipRequestsEmptyResults.js | 47 ++++ .../MembershipRequestsResultItem.js | 114 +++++++++ .../MembershipRequestsResultsContainer.js | 36 +++ .../MembershipRequestsSearchLayout.js | 65 +++++ .../members/membership_requests/index.js | 93 +++++++ invenio_communities/config.py | 26 ++ invenio_communities/members/records/api.py | 4 +- .../members/resources/resource.py | 15 ++ .../members/services/config.py | 10 +- invenio_communities/members/services/links.py | 228 ++++++++++++++++++ .../members/services/request.py | 26 ++ .../members/services/service.py | 42 +++- invenio_communities/permissions.py | 1 + invenio_communities/searchapp.py | 8 + .../details/members/base.html | 1 + .../details/members/membership_requests.html | 37 +++ invenio_communities/views/communities.py | 16 ++ invenio_communities/views/ui.py | 3 + invenio_communities/webpack.py | 1 + tests/members/test_members_resource.py | 54 ++++- tests/members/test_members_services.py | 3 + 25 files changed, 971 insertions(+), 15 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 create mode 100644 invenio_communities/assets/semantic-ui/js/invenio_communities/members/MemberRequestsSearchBarElement.js 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/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/MemberRequestsSearchBarElement.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/MemberRequestsSearchBarElement.js new file mode 100644 index 000000000..36a37fb68 --- /dev/null +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/MemberRequestsSearchBarElement.js @@ -0,0 +1,59 @@ +/* + * 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 { Input } from "semantic-ui-react"; +import { i18next } from "@translations/invenio_communities/i18next"; +import PropTypes from "prop-types"; + +export const MemberRequestsSearchBarElement = ({ + onBtnSearchClick, + onInputChange, + onKeyPress, + queryString, + uiProps, + className, + placeholder, +}) => { + return ( + { + onInputChange(value); + }} + value={queryString} + onKeyPress={onKeyPress} + {...uiProps} + /> + ); +}; + +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, +}; + +MemberRequestsSearchBarElement.defaultProps = { + uiProps: null, + className: "", + placeholder: "", +}; 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..881c6cee0 --- /dev/null +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/MembershipRequestsResultItem.js @@ -0,0 +1,114 @@ +/* + * 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 { DateTime } from "luxon"; +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"; + +const formattedTime = (expiresAt) => + DateTime.fromISO(expiresAt).setLocale(i18next.language).toRelative(); + +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..e96757868 --- /dev/null +++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/members/membership_requests/index.js @@ -0,0 +1,93 @@ +/* + * 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 { + 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, + "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/config.py b/invenio_communities/config.py index 4bcb38b2a..111ef584a 100644 --- a/invenio_communities/config.py +++ b/invenio_communities/config.py @@ -30,6 +30,7 @@ "invitations": "/communities//invitations", "about": "/communities//about", "curation_policy": "/communities//curation-policy", + "membership_requests": "/communities//membership-requests" } """Communities ui endpoints.""" @@ -203,6 +204,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.""" diff --git a/invenio_communities/members/records/api.py b/invenio_communities/members/records/api.py index d74f581a8..4894a0f43 100644 --- a/invenio_communities/members/records/api.py +++ b/invenio_communities/members/records/api.py @@ -43,10 +43,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.""" diff --git a/invenio_communities/members/resources/resource.py b/invenio_communities/members/resources/resource.py index d65828887..8bdd8dce9 100644 --- a/invenio_communities/members/resources/resource.py +++ b/invenio_communities/members/resources/resource.py @@ -36,6 +36,8 @@ 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 +146,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..27b44604d 100644 --- a/invenio_communities/members/services/config.py +++ b/invenio_communities/members/services/config.py @@ -29,6 +29,7 @@ from . import facets from .components import CommunityMemberCachingComponent from .schemas import MemberEntitySchema +from .links import LinksForActionsOfMember, LinksForRequestActionsOfMember class PublicSearchOptions(SearchOptions): @@ -182,11 +183,14 @@ 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}"), # noqa + ]) + } # ResultList configurations - links_search = pagination_links("{+api}/communities/{community_id}/members{?args*}") + links_search = pagination_links("{+api}/communities/{community_id}/{endpoint}{?args*}") # noqa # 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..6b9a44ede --- /dev/null +++ b/invenio_communities/members/services/links.py @@ -0,0 +1,228 @@ +# -*- 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. + +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, + ) + return ( + RequestActions.can_execute(request, action_for_execute) + and permission.allows(identity) + ) diff --git a/invenio_communities/members/services/request.py b/invenio_communities/members/services/request.py index 2dba594e2..fbc52e85a 100644 --- a/invenio_communities/members/services/request.py +++ b/invenio_communities/members/services/request.py @@ -145,6 +145,19 @@ def execute(self, identity, uow): super().execute(identity, uow) +class AcceptMembershipRequestAction(actions.AcceptAction): + def execute(self, identity, uow): + """Execute action.""" + # TODO: Decision flow: Implement me + pass + + +class DeclineMembershipRequestAction(actions.DeclineAction): + def execute(self, identity, uow): + """Execute action.""" + # TODO: Decision flow: Implement me + pass + class MembershipRequestRequestType(RequestType): """Request type for membership requests.""" @@ -155,6 +168,8 @@ class MembershipRequestRequestType(RequestType): available_actions = { "create": actions.CreateAndSubmitAction, "cancel": CancelMembershipRequestAction, + "accept": AcceptMembershipRequestAction, + "decline": DeclineMembershipRequestAction, } creator_can_be_none = False @@ -162,3 +177,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/service.py b/invenio_communities/members/services/service.py index db1db57f8..842191c92 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, @@ -293,7 +292,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 +361,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 +391,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 +416,8 @@ def search_public( ), params=params, search_preference=search_preference, + endpoint="members", + links_item_tpl=LinksTemplate(self.config.links_item), **kwargs ) @@ -432,6 +438,10 @@ def search_invitations( extra_filter=dsl.Q("term", **{"active": False}), params=params, search_preference=search_preference, + endpoint="invitations", + links_item_tpl=MemberLinksTemplate( + self.config.links_item, + request_type=CommunityInvitation), **kwargs ) @@ -447,6 +457,8 @@ def _members_search( search_preference=None, scan=False, scan_params=None, + endpoint="members", + links_item_tpl=None, **kwargs ): """Members search.""" @@ -491,9 +503,11 @@ def _members_search( context={ "args": params, "community_id": community_id, + "endpoint": endpoint, }, ) ), + links_item_tpl=links_item_tpl, schema=schema, ) @@ -831,10 +845,26 @@ 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.invitation_dump_schema, # TODO: change + self.config.search_invitations, # TODO: change + record_cls=ArchivedInvitation, # TODO: Decision flow: merge or new? + extra_filter=dsl.Q("term", **{"active": False}), + 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): diff --git a/invenio_communities/permissions.py b/invenio_communities/permissions.py index c54845db5..6c8914c8d 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 2ec8f82bb..7e1724ee8 100644 --- a/invenio_communities/searchapp.py +++ b/invenio_communities/searchapp.py @@ -53,4 +53,12 @@ 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/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), } %}