diff --git a/invenio_communities/generators.py b/invenio_communities/generators.py index 0d5bcbdbc..1949adab1 100644 --- a/invenio_communities/generators.py +++ b/invenio_communities/generators.py @@ -201,6 +201,7 @@ def query_filter(self, **kwargs): # Community membership generators # + class AuthenticatedButNotCommunityMembers(Generator): """Authenticated user not part of community.""" @@ -220,6 +221,7 @@ def excludes(self, record=None, **kwargs): community_id = str(record.id) return [CommunityRoleNeed(community_id, r.name) for r in current_roles] + class CommunityRoles(Generator): """Base class for community roles generators.""" diff --git a/invenio_communities/members/resources/config.py b/invenio_communities/members/resources/config.py index 8e63d54df..29eb164fa 100644 --- a/invenio_communities/members/resources/config.py +++ b/invenio_communities/members/resources/config.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # Copyright (C) 2022 KTH Royal Institute of Technology -# Copyright (C) 2022 Northwestern University. +# Copyright (C) 2022-2024 Northwestern University. # Copyright (C) 2022 CERN. # Copyright (C) 2023 TU Wien. # @@ -29,6 +29,7 @@ class MemberResourceConfig(RecordResourceConfig): "members": "/communities//members", "publicmembers": "/communities//members/public", "invitations": "/communities//invitations", + "membership_requests": "/communities//membership-requests", } request_view_args = { "pid_value": ma.fields.UUID(), diff --git a/invenio_communities/members/resources/resource.py b/invenio_communities/members/resources/resource.py index ee5a2fa55..d65828887 100644 --- a/invenio_communities/members/resources/resource.py +++ b/invenio_communities/members/resources/resource.py @@ -31,10 +31,11 @@ def create_url_rules(self): route("DELETE", routes["members"], self.delete), route("PUT", routes["members"], self.update), route("GET", routes["members"], self.search), - route("POST", routes["invitations"], self.invite), route("GET", routes["publicmembers"], self.search_public), + route("POST", routes["invitations"], self.invite), route("PUT", routes["invitations"], self.update_invitations), route("GET", routes["invitations"], self.search_invitations), + route("POST", routes["membership_requests"], self.request_membership), ] @request_view_args @@ -98,6 +99,17 @@ def invite(self): ) return "", 204 + @request_view_args + @request_data + def request_membership(self): + """Request membership.""" + request = self.service.request_membership( + g.identity, + resource_requestctx.view_args["pid_value"], + resource_requestctx.data, + ) + return request.to_dict(), 201 + @request_view_args @request_extra_args @request_data diff --git a/invenio_communities/members/services/request.py b/invenio_communities/members/services/request.py index 04dec909f..ddc7d181b 100644 --- a/invenio_communities/members/services/request.py +++ b/invenio_communities/members/services/request.py @@ -124,3 +124,21 @@ class CommunityInvitation(RequestType): "manager", ] } + + +class MembershipRequestRequestType(RequestType): + """Request type for membership requests.""" + + type_id = "community-membership-request" + name = _("Membership request") + + create_action = "create" + available_actions = { + "create": actions.CreateAndSubmitAction, + } + + creator_can_be_none = False + topic_can_be_none = False + allowed_creator_ref_types = ["user"] + allowed_receiver_ref_types = ["community"] + allowed_topic_ref_types = ["community"] diff --git a/invenio_communities/members/services/schemas.py b/invenio_communities/members/services/schemas.py index bc73c64e1..ad9f9f71f 100644 --- a/invenio_communities/members/services/schemas.py +++ b/invenio_communities/members/services/schemas.py @@ -93,6 +93,12 @@ class DeleteBulkSchema(MembersSchema): """Delete bulk schema.""" +class RequestMembershipSchema(Schema): + """Schema used for requesting membership.""" + + message = SanitizedUnicode() + + # # Schemas used for dumping a single member # diff --git a/invenio_communities/members/services/service.py b/invenio_communities/members/services/service.py index e911d9dfd..1c05bd28d 100644 --- a/invenio_communities/members/services/service.py +++ b/invenio_communities/members/services/service.py @@ -40,7 +40,7 @@ from ...proxies import current_roles from ..errors import AlreadyMemberError, InvalidMemberError from ..records.api import ArchivedInvitation -from .request import CommunityInvitation +from .request import CommunityInvitation, MembershipRequestRequestType from .schemas import ( AddBulkSchema, DeleteBulkSchema, @@ -48,6 +48,7 @@ InviteBulkSchema, MemberDumpSchema, PublicDumpSchema, + RequestMembershipSchema, UpdateBulkSchema, ) @@ -103,6 +104,11 @@ def delete_schema(self): """Schema for bulk delete.""" return ServiceSchemaWrapper(self, schema=DeleteBulkSchema) + @property + def request_membership_schema(self): + """Wrapped schema for request membership.""" + return ServiceSchemaWrapper(self, schema=RequestMembershipSchema) + @property def archive_indexer(self): """Factory for creating an indexer instance.""" @@ -734,3 +740,110 @@ def rebuild_index(self, identity, uow=None): self.archive_indexer.bulk_index([inv.id for inv in archived_invitations]) return True + + # Request membership + @unit_of_work() + def request_membership(self, identity, community_id, data, uow=None): + """Request membership to the community. + + A user can only have one request per community. + + All validations raise, so it's up to parent layer to handle them. + """ + community = self.community_cls.get_record(community_id) + + data, errors = self.request_membership_schema.load( + data, + context={"identity": identity}, + ) + message = data.get("message", "") + + self.require_permission( + identity, + "request_membership", + record=community, + ) + + # Create request + title = _('Request to join "{community}"').format( + community=community.metadata["title"], + ) + request_item = current_requests_service.create( + identity, + data={ + "title": title, + # "description": description, + }, + request_type=MembershipRequestRequestType, + receiver=community, + creator={"user": str(identity.user.id)}, + topic=community, # user instead? + # TODO: Consider expiration + # expires_at=invite_expires_at(), + uow=uow, + ) + + if message: + data = {"payload": {"content": message}} + current_events_service.create( + identity, + request_item.id, + data, + CommentEventType, + uow=uow, + notify=False, + ) + + # TODO: Add notification mechanism + # uow.register( + # NotificationOp( + # MembershipRequestSubmittedNotificationBuilder.build( + # request=request_item._request, + # # explicit string conversion to get the value of LazyText + # role=str(role.title), + # message=message, + # ) + # ) + # ) + + # Create an inactive member entry linked to the request. + self._add_factory( + identity, + community=community, + role=current_roles["reader"], + visible=False, + member={"type": "user", "id": str(identity.user.id)}, + message=message, + uow=uow, + active=False, + request_id=request_item.id, + ) + + # No registered component with a request_membership method for now, + # so no run_components for now. + + # Has to return the request so that frontend can redirect to it + return request_item + + @unit_of_work() + def update_membership_request(self, identity, community_id, data, uow=None): + """Update membership request.""" + # TODO: Implement me + pass + + def search_membership_requests(self): + """Search membership requests.""" + # TODO: Implement me + pass + + @unit_of_work() + def accept_membership_request(self, identity, request_id, uow=None): + """Accept membership request.""" + # TODO: Implement me + pass + + @unit_of_work() + def decline_membership_request(self, identity, request_id, uow=None): + """Decline membership request.""" + # TODO: Implement me + pass diff --git a/invenio_communities/permissions.py b/invenio_communities/permissions.py index 3c87d9ed2..c54845db5 100644 --- a/invenio_communities/permissions.py +++ b/invenio_communities/permissions.py @@ -193,13 +193,14 @@ class CommunityPermissionPolicy(BasePermissionPolicy): IfPolicyClosed( "member_policy", then_=[Disable()], - else_=[AuthenticatedButNotCommunityMembers()] - ) + else_=[AuthenticatedButNotCommunityMembers()], + ), ], - else_=[Disable()] + else_=[Disable()], ), ] + def can_perform_action(community, context): """Check if the given action is available on the request.""" action = context.get("action") diff --git a/setup.cfg b/setup.cfg index c9be03558..d6045cd83 100644 --- a/setup.cfg +++ b/setup.cfg @@ -80,6 +80,7 @@ invenio_requests.entity_resolvers = communities = invenio_communities.communities.entity_resolvers:CommunityResolver invenio_requests.types = community_invitation = invenio_communities.members.services.request:CommunityInvitation + membership_request_request_type = invenio_communities.members.services.request:MembershipRequestRequestType invenio_i18n.translations = messages = invenio_communities invenio_administration.views = @@ -94,8 +95,6 @@ invenio_base.finalize_app = invenio_base.api_finalize_app = invenio_communities = invenio_communities.ext:api_finalize_app - - [build_sphinx] source-dir = docs/ build-dir = docs/_build diff --git a/tests/conftest.py b/tests/conftest.py index 33e71b64f..6e68819a6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -143,6 +143,8 @@ def app_config(app_config): # When testing unverified users, there is a "unverified_user" fixture for that purpose. app_config["ACCOUNTS_DEFAULT_USERS_VERIFIED"] = True + app_config["COMMUNITIES_ALLOW_MEMBERSHIP_REQUESTS"] = True + return app_config @@ -361,6 +363,45 @@ def new_user(UserFixture, app, database): return u +@pytest.fixture(scope="function") +def create_user(UserFixture, app, db): + """Create user factory fixture. + + It's function scope is key here as it guarantees a user devoid of any prior which + is essential for many tests. + """ + + def _create_user(data): + """Create user.""" + default_data = dict( + email="user@example.org", + password="user", + username="user", + user_profile={ + "full_name": "Created User", + "affiliations": "CERN", + }, + preferences={ + "visibility": "public", + "email_visibility": "restricted", + "notifications": { + "enabled": True, + }, + }, + active=True, + confirmed=True, + ) + actual_data = dict(default_data, **data) + u = UserFixture(**actual_data) + u.create(app, db) + current_users_service.indexer.process_bulk_queue() + current_users_service.record_cls.index.refresh() + db.session.commit() + return u + + return _create_user + + # # Communities # diff --git a/tests/members/test_members_resource.py b/tests/members/test_members_resource.py index 8b7c920d9..ea75a911c 100644 --- a/tests/members/test_members_resource.py +++ b/tests/members/test_members_resource.py @@ -11,6 +11,7 @@ import pytest from invenio_access.permissions import system_identity from invenio_requests.records.api import RequestEvent +from invenio_users_resources.proxies import current_users_service # @@ -134,7 +135,7 @@ def test_invite_deny(client, headers, community_id, new_user, new_user_data, db) # # Update # -def test_update(client, headers, community_id, owner, public_reader): +def test_update(client, headers, community_id, owner, public_reader, db): """Test update of members.""" client = owner.login(client) data = { @@ -357,3 +358,73 @@ def test_search_invitation( # TODO: facet by role, facet by visibility, define sorts. # TODO: same user can be invited to two different communities # TODO: same user/group can be added to two different communities + + +# +# Membership request +# + + +def test_post_membership_requests(app, client, headers, community_id, create_user, db): + user = create_user({"email": "user_foo@example.org", "username": "user_foo"}) + client = user.login(client) + + # Post membership request + r = client.post( + f"/communities/{community_id}/membership-requests", + headers=headers, + json={"message": "Can I join the club?"}, + ) + assert 201 == r.status_code + + RequestEvent.index.refresh() + + # Get links to check + url_of_request = r.json["links"]["self"].replace(app.config["SITE_API_URL"], "") + url_of_timeline = r.json["links"]["timeline"].replace( + app.config["SITE_API_URL"], + "", + ) + + # Check the request + r = client.get(url_of_request, headers=headers) + assert 200 == r.status_code + assert 'Request to join "My Community"' in r.json["title"] + + # Check the timeline + r = client.get(url_of_timeline, headers=headers) + assert 200 == r.status_code + assert 1 == r.json["hits"]["total"] + msg = r.json["hits"]["hits"][0]["payload"]["content"] + assert "Can I join the club?" == msg + + +def test_put_membership_requests( + client, headers, community_id, owner, new_user_data, db +): + # update membership request + # TODO: Implement me! + assert True + + +def test_error_handling_for_membership_requests( + client, headers, community_id, owner, new_user_data, db +): + # TODO: Implement me! + # error handling registered + # - permission handling registered + # - duplicate handling registered + 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"]