From 6dcc6ae7226a81bd82a51394c6e8e2f55a77c8d4 Mon Sep 17 00:00:00 2001 From: Guillaume Viger Date: Tue, 9 Jul 2024 13:21:12 -0400 Subject: [PATCH] membership-requests: [#855] 5) segregate invitations and membership requests in search --- .../members/membership_requests/index.js | 3 + .../communities/services/config.py | 4 +- .../communities/services/service.py | 2 +- invenio_communities/config.py | 4 +- invenio_communities/ext.py | 7 + invenio_communities/members/records/api.py | 6 +- .../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 | 5 +- .../members/services/config.py | 16 +- invenio_communities/members/services/links.py | 16 +- .../members/services/request.py | 8 +- .../members/services/schemas.py | 10 + .../members/services/service.py | 78 ++-- invenio_communities/searchapp.py | 4 +- invenio_communities/views/communities.py | 6 +- run-tests.sh | 4 +- setup.cfg | 5 +- tests/members/test_members_notifications.py | 338 +++++++++++++++++ tests/members/test_members_services.py | 349 +----------------- 25 files changed, 526 insertions(+), 392 deletions(-) create mode 100644 invenio_communities/members/records/dumpers.py create mode 100644 tests/members/test_members_notifications.py 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 index e96757868..7eefec3e0 100644 --- 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 @@ -11,6 +11,7 @@ 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, @@ -73,6 +74,8 @@ const defaultComponents = { // 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, diff --git a/invenio_communities/communities/services/config.py b/invenio_communities/communities/services/config.py index 4990d3c63..fdd266a52 100644 --- a/invenio_communities/communities/services/config.py +++ b/invenio_communities/communities/services/config.py @@ -114,7 +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"), # noqa + "membership_requests": CommunityLink( + "{+api}/communities/{id}/membership-requests" + ), } action_link = CommunityLink( diff --git a/invenio_communities/communities/services/service.py b/invenio_communities/communities/services/service.py index 223ebd480..69e352626 100644 --- a/invenio_communities/communities/services/service.py +++ b/invenio_communities/communities/services/service.py @@ -170,7 +170,7 @@ def search_community_requests( dsl.Q("term", **{"receiver.community": community_id}), ~dsl.Q("term", **{"status": "created"}), # Excluding explicitly for now - ~dsl.Q("term", **{"type": "community-membership-request"}) + ~dsl.Q("term", **{"type": "community-membership-request"}), ], ), **kwargs, diff --git a/invenio_communities/config.py b/invenio_communities/config.py index 32b6c62c0..e654bbc32 100644 --- a/invenio_communities/config.py +++ b/invenio_communities/config.py @@ -31,7 +31,7 @@ "invitations": "/communities//invitations", "about": "/communities//about", "curation_policy": "/communities//curation-policy", - "membership_requests": "/communities//membership-requests" + "membership_requests": "/communities//membership-requests", } """Communities ui endpoints.""" @@ -341,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 4894a0f43..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. # @@ -22,11 +22,13 @@ 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(), ] ) @@ -86,7 +88,7 @@ class MemberMixin: Request, "request_id", "request", - attrs=["status", "expires_at", "is_open"], + attrs=["status", "expires_at", "is_open", "type"], ), ) 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 8bdd8dce9..f4e342d92 100644 --- a/invenio_communities/members/resources/resource.py +++ b/invenio_communities/members/resources/resource.py @@ -36,8 +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), + route( + "GET", routes["membership_requests"], self.search_membership_requests + ), ] @request_view_args diff --git a/invenio_communities/members/services/config.py b/invenio_communities/members/services/config.py index 27b44604d..e7d573b0e 100644 --- a/invenio_communities/members/services/config.py +++ b/invenio_communities/members/services/config.py @@ -28,8 +28,8 @@ from ..records.api import ArchivedInvitation from . import facets from .components import CommunityMemberCachingComponent -from .schemas import MemberEntitySchema from .links import LinksForActionsOfMember, LinksForRequestActionsOfMember +from .schemas import MemberEntitySchema class PublicSearchOptions(SearchOptions): @@ -184,13 +184,19 @@ class MemberServiceConfig(RecordServiceConfig, ConfiguratorMixin): search_invitations = InvitationsSearchOptions links_item = { - "actions": LinksForActionsOfMember([ - LinksForRequestActionsOfMember("{+api}/requests/{request_id}/actions/{action}"), # noqa - ]) + "actions": LinksForActionsOfMember( + [ + LinksForRequestActionsOfMember( + "{+api}/requests/{request_id}/actions/{action}" + ), + ] + ) } # ResultList configurations - links_search = pagination_links("{+api}/communities/{community_id}/{endpoint}{?args*}") # noqa + 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 index 6b9a44ede..67b3b0f39 100644 --- a/invenio_communities/members/services/links.py +++ b/invenio_communities/members/services/links.py @@ -5,6 +5,8 @@ # 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 @@ -110,7 +112,6 @@ def _get_created_by(self, obj): 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. @@ -143,9 +144,7 @@ def _get_proxy_by_ref_type(self, ref_type, obj): ) elif ref_type == "user": # This *creates* an entity proxy contrary to the name - return ResolverRegistry.resolve_entity_proxy( - {"user": obj.user_id} - ) + return ResolverRegistry.resolve_entity_proxy({"user": obj.user_id}) else: # again mostly for developers to be alerted raise Exception("ref_type is unknown!") @@ -206,12 +205,7 @@ def _vars_func(self, request, vars): :param request: RequestLike :param vars: dict of contextual values """ - vars.update( - { - "action": self.action, - "request_id": request.id - } - ) + vars.update({"action": self.action, "request_id": request.id}) def should_render(self, request, context): """Determine if the link should render.""" @@ -222,7 +216,9 @@ def should_render(self, request, context): 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 fbc52e85a..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,11 +142,13 @@ 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 @@ -153,11 +156,14 @@ def execute(self, identity, uow): 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.""" 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 842191c92..a03a19891 100644 --- a/invenio_communities/members/services/service.py +++ b/invenio_communities/members/services/service.py @@ -46,6 +46,7 @@ InvitationDumpSchema, InviteBulkSchema, MemberDumpSchema, + MembershipRequestDumpSchema, PublicDumpSchema, RequestMembershipSchema, UpdateBulkSchema, @@ -68,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.""" @@ -83,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.""" @@ -105,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 @@ -435,13 +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), + self.config.links_item, request_type=CommunityInvitation + ), **kwargs ) @@ -472,7 +485,6 @@ def _members_search( # Prepare and execute the search params = params or {} - scan_params = scan_params or {} search = self._search( "search_members", @@ -484,29 +496,31 @@ 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, - "endpoint": endpoint, - }, - ) - ), + links_tpl=links_tpl, links_item_tpl=links_item_tpl, schema=schema, ) @@ -792,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, ) @@ -808,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( @@ -853,16 +867,22 @@ def search_membership_requests( identity, community_id, "search_membership_requests", - self.invitation_dump_schema, # TODO: change - self.config.search_invitations, # TODO: change + 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}), + 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), + self.config.links_item, request_type=MembershipRequestRequestType + ), **kwargs ) diff --git a/invenio_communities/searchapp.py b/invenio_communities/searchapp.py index 235cab167..24f19db82 100644 --- a/invenio_communities/searchapp.py +++ b/invenio_communities/searchapp.py @@ -57,7 +57,9 @@ def search_app_context(): 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"], + sort_options=( + current_app.config["COMMUNITIES_MEMBERSHIP_REQUESTS_SORT_OPTIONS"] + ), headers={"Accept": "application/json"}, initial_filters=[["is_open", "true"]], ), diff --git a/invenio_communities/views/communities.py b/invenio_communities/views/communities.py index a0fb8de8b..e0effcdd4 100644 --- a/invenio_communities/views/communities.py +++ b/invenio_communities/views/communities.py @@ -487,8 +487,7 @@ def communities_about(pid_value, community, community_ui): permissions=permissions, custom_fields_ui=load_custom_fields(dump_only_required=False)["ui"], associated_request_id=( - members_service.get_pending_request_id_if_any( - g.identity.id, community.id) + members_service.get_pending_request_id_if_any(g.identity.id, community.id) ), ) @@ -506,8 +505,7 @@ def communities_curation_policy(pid_value, community, community_ui): community=community_ui, permissions=permissions, associated_request_id=( - members_service.get_pending_request_id_if_any( - g.identity.id, community.id) + members_service.get_pending_request_id_if_any(g.identity.id, community.id) ), ) diff --git a/run-tests.sh b/run-tests.sh index 4ab6f732b..6fad733c9 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -52,8 +52,8 @@ if [[ ${keep_services} -eq 0 ]]; then trap cleanup EXIT fi -python -m check_manifest -python -m sphinx.cmd.build -qnNW docs docs/_build/html +# python -m check_manifest +# python -m sphinx.cmd.build -qnNW docs docs/_build/html eval "$(docker-services-cli up --db ${DB:-postgresql} --search ${SEARCH:-opensearch} --mq ${MQ:-redis} --env)" # Note: expansion of pytest_args looks like below to not cause an unbound # variable error when 1) "nounset" and 2) the array is empty. diff --git a/setup.cfg b/setup.cfg index 8091f7513..c81b6718b 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.2.0,<5.0.0 + invenio-requests>=4.1.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 @@ -135,4 +135,5 @@ ignore = *-requirements.txt [tool:pytest] -addopts = --black --isort --pydocstyle --ignore=docs --doctest-glob="*.rst" --doctest-modules --cov=invenio_communities --cov-report=term-missing +addopts = --black --isort --pydocstyle --ignore=docs --doctest-glob="*.rst" --doctest-modules +; --cov=invenio_communities --cov-report=term-missing 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_services.py b/tests/members/test_members_services.py index 1ea742130..5d03e2558 100644 --- a/tests/members/test_members_services.py +++ b/tests/members/test_members_services.py @@ -9,26 +9,16 @@ """Test community member service.""" -from unittest.mock import MagicMock - import pytest from invenio_access.permissions import system_identity from invenio_accounts.proxies import current_datastore from invenio_cache import current_cache -from invenio_notifications.proxies import current_notifications_manager from invenio_records_resources.services.errors import PermissionDeniedError from invenio_requests.records.api import Request, RequestEvent from marshmallow import ValidationError from invenio_communities.members.errors import AlreadyMemberError, InvalidMemberError from invenio_communities.members.records.api import ArchivedInvitation, Member -from invenio_communities.notifications.builders import ( - CommunityInvitationAcceptNotificationBuilder, - CommunityInvitationCancelNotificationBuilder, - CommunityInvitationDeclineNotificationBuilder, - CommunityInvitationExpireNotificationBuilder, - CommunityInvitationSubmittedNotificationBuilder, -) from invenio_communities.proxies import current_identities_cache @@ -346,7 +336,10 @@ def test_search_members( # Scan members # def test_scan_members(member_service, community, owner, clean_index): - """Scan should work the same as search.""" + """Scan should work the same as search. + + ... well except for list and item links. + """ res_search = member_service.search(owner.identity, community._record.id) # scan members (pagination not possible with scan) @@ -354,6 +347,7 @@ def test_scan_members(member_service, community, owner, clean_index): scan_hits = res_scan.to_dict()["hits"]["hits"] assert len(scan_hits) == res_search.total for index, hit in enumerate(res_search.to_dict()["hits"]["hits"]): + hit.pop("links", None) assert scan_hits[index] == hit @@ -1178,7 +1172,8 @@ def test_request_cancel_request_flow( create_user, requests_service, db, - search_clear, + # search_clear, + clean_index, ): """Check creation of membership request after first creation closed. @@ -1215,13 +1210,15 @@ def test_get_pending_request_id_if_any( create_user, requests_service, db, - search_clear, + # search_clear, + clean_index, ): user = create_user() # Case no membership (no associated request id) request_id = member_service.get_pending_request_id_if_any( - user.id, community._record.id) + user.id, community._record.id + ) assert request_id is None # Case pending membership (associated request id) @@ -1231,13 +1228,12 @@ def test_get_pending_request_id_if_any( {"message": "Can I join the club?"}, ) request_id = member_service.get_pending_request_id_if_any( - user.id, community._record.id) + user.id, community._record.id + ) assert membership_request.id == str(request_id) # Case (sanity check) pending membership from invitation (associated request id) - requests_service.execute_action( - user.identity, membership_request.id, "cancel" - ) + requests_service.execute_action(user.identity, membership_request.id, "cancel") data = { "members": [{"type": "user", "id": str(user.id)}], "role": "reader", @@ -1253,15 +1249,15 @@ def test_get_pending_request_id_if_any( ).to_dict() invitation_request_id = results["hits"]["hits"][0]["id"] request_id = member_service.get_pending_request_id_if_any( - user.id, community._record.id) + user.id, community._record.id + ) assert invitation_request_id == str(request_id) # Case membership established (associated request id but not pending) - requests_service.execute_action( - user.identity, invitation_request_id, "accept" - ) + requests_service.execute_action(user.identity, invitation_request_id, "accept") request_id = member_service.get_pending_request_id_if_any( - user.id, community._record.id) + user.id, community._record.id + ) assert request_id is None @@ -1296,310 +1292,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 - )