Skip to content

Commit f8727c5

Browse files
committed
resources: [inveniosoftware#855] add POST request_membership
1 parent d13885d commit f8727c5

File tree

10 files changed

+273
-9
lines changed

10 files changed

+273
-9
lines changed

invenio_communities/generators.py

+2
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ def query_filter(self, **kwargs):
200200
# Community membership generators
201201
#
202202

203+
203204
class AuthenticatedButNotCommunityMembers(Generator):
204205
"""Authenticated user not part of community."""
205206

@@ -219,6 +220,7 @@ def excludes(self, record=None, **kwargs):
219220
community_id = str(record.id)
220221
return [CommunityRoleNeed(community_id, r.name) for r in current_roles]
221222

223+
222224
class CommunityRoles(Generator):
223225
"""Base class for community roles generators."""
224226

invenio_communities/members/resources/config.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# -*- coding: utf-8 -*-
22
#
33
# Copyright (C) 2022 KTH Royal Institute of Technology
4-
# Copyright (C) 2022 Northwestern University.
4+
# Copyright (C) 2022-2024 Northwestern University.
55
# Copyright (C) 2022 CERN.
66
# Copyright (C) 2023 TU Wien.
77
#
@@ -29,6 +29,7 @@ class MemberResourceConfig(RecordResourceConfig):
2929
"members": "/communities/<pid_value>/members",
3030
"publicmembers": "/communities/<pid_value>/members/public",
3131
"invitations": "/communities/<pid_value>/invitations",
32+
"membership_requests": "/communities/<pid_value>/membership-requests",
3233
}
3334
request_view_args = {
3435
"pid_value": ma.fields.UUID(),

invenio_communities/members/resources/resource.py

+13-1
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,11 @@ def create_url_rules(self):
3131
route("DELETE", routes["members"], self.delete),
3232
route("PUT", routes["members"], self.update),
3333
route("GET", routes["members"], self.search),
34-
route("POST", routes["invitations"], self.invite),
3534
route("GET", routes["publicmembers"], self.search_public),
35+
route("POST", routes["invitations"], self.invite),
3636
route("PUT", routes["invitations"], self.update_invitations),
3737
route("GET", routes["invitations"], self.search_invitations),
38+
route("POST", routes["membership_requests"], self.request_membership),
3839
]
3940

4041
@request_view_args
@@ -98,6 +99,17 @@ def invite(self):
9899
)
99100
return "", 204
100101

102+
@request_view_args
103+
@request_data
104+
def request_membership(self):
105+
"""Request membership."""
106+
request = self.service.request_membership(
107+
g.identity,
108+
resource_requestctx.view_args["pid_value"],
109+
resource_requestctx.data,
110+
)
111+
return request.to_dict(), 201
112+
101113
@request_view_args
102114
@request_extra_args
103115
@request_data

invenio_communities/members/services/request.py

+18
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,21 @@ class CommunityInvitation(RequestType):
124124
"manager",
125125
]
126126
}
127+
128+
129+
class MembershipRequestRequestType(RequestType):
130+
"""Request type for membership requests."""
131+
132+
type_id = "community-membership-request"
133+
name = _("Membership request")
134+
135+
create_action = "create"
136+
available_actions = {
137+
"create": actions.CreateAndSubmitAction,
138+
}
139+
140+
creator_can_be_none = False
141+
topic_can_be_none = False
142+
allowed_creator_ref_types = ["user"]
143+
allowed_receiver_ref_types = ["community"]
144+
allowed_topic_ref_types = ["community"]

invenio_communities/members/services/schemas.py

+6
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ class DeleteBulkSchema(MembersSchema):
9393
"""Delete bulk schema."""
9494

9595

96+
class RequestMembershipSchema(Schema):
97+
"""Schema used for requesting membership."""
98+
99+
message = SanitizedUnicode()
100+
101+
96102
#
97103
# Schemas used for dumping a single member
98104
#

invenio_communities/members/services/service.py

+114-1
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,15 @@
4040
from ...proxies import current_roles
4141
from ..errors import AlreadyMemberError, InvalidMemberError
4242
from ..records.api import ArchivedInvitation
43-
from .request import CommunityInvitation
43+
from .request import CommunityInvitation, MembershipRequestRequestType
4444
from .schemas import (
4545
AddBulkSchema,
4646
DeleteBulkSchema,
4747
InvitationDumpSchema,
4848
InviteBulkSchema,
4949
MemberDumpSchema,
5050
PublicDumpSchema,
51+
RequestMembershipSchema,
5152
UpdateBulkSchema,
5253
)
5354

@@ -103,6 +104,11 @@ def delete_schema(self):
103104
"""Schema for bulk delete."""
104105
return ServiceSchemaWrapper(self, schema=DeleteBulkSchema)
105106

107+
@property
108+
def request_membership_schema(self):
109+
"""Wrapped schema for request membership."""
110+
return ServiceSchemaWrapper(self, schema=RequestMembershipSchema)
111+
106112
@property
107113
def archive_indexer(self):
108114
"""Factory for creating an indexer instance."""
@@ -734,3 +740,110 @@ def rebuild_index(self, identity, uow=None):
734740
self.archive_indexer.bulk_index([inv.id for inv in archived_invitations])
735741

736742
return True
743+
744+
# Request membership
745+
@unit_of_work()
746+
def request_membership(self, identity, community_id, data, uow=None):
747+
"""Request membership to the community.
748+
749+
A user can only have one request per community.
750+
751+
All validations raise, so it's up to parent layer to handle them.
752+
"""
753+
community = self.community_cls.get_record(community_id)
754+
755+
data, errors = self.request_membership_schema.load(
756+
data,
757+
context={"identity": identity},
758+
)
759+
message = data.get("message", "")
760+
761+
self.require_permission(
762+
identity,
763+
"request_membership",
764+
record=community,
765+
)
766+
767+
# Create request
768+
title = _('Request to join "{community}"').format(
769+
community=community.metadata["title"],
770+
)
771+
request_item = current_requests_service.create(
772+
identity,
773+
data={
774+
"title": title,
775+
# "description": description,
776+
},
777+
request_type=MembershipRequestRequestType,
778+
receiver=community,
779+
creator={"user": str(identity.user.id)},
780+
topic=community, # user instead?
781+
# TODO: Consider expiration
782+
# expires_at=invite_expires_at(),
783+
uow=uow,
784+
)
785+
786+
if message:
787+
data = {"payload": {"content": message}}
788+
current_events_service.create(
789+
identity,
790+
request_item.id,
791+
data,
792+
CommentEventType,
793+
uow=uow,
794+
notify=False,
795+
)
796+
797+
# TODO: Add notification mechanism
798+
# uow.register(
799+
# NotificationOp(
800+
# MembershipRequestSubmittedNotificationBuilder.build(
801+
# request=request_item._request,
802+
# # explicit string conversion to get the value of LazyText
803+
# role=str(role.title),
804+
# message=message,
805+
# )
806+
# )
807+
# )
808+
809+
# Create an inactive member entry linked to the request.
810+
self._add_factory(
811+
identity,
812+
community=community,
813+
role=current_roles["reader"],
814+
visible=False,
815+
member={"type": "user", "id": str(identity.user.id)},
816+
message=message,
817+
uow=uow,
818+
active=False,
819+
request_id=request_item.id,
820+
)
821+
822+
# No registered component with a request_membership method for now,
823+
# so no run_components for now.
824+
825+
# Has to return the request so that frontend can redirect to it
826+
return request_item
827+
828+
@unit_of_work()
829+
def update_membership_request(self, identity, community_id, data, uow=None):
830+
"""Update membership request."""
831+
# TODO: Implement me
832+
pass
833+
834+
def search_membership_requests(self):
835+
"""Search membership requests."""
836+
# TODO: Implement me
837+
pass
838+
839+
@unit_of_work()
840+
def accept_membership_request(self, identity, request_id, uow=None):
841+
"""Accept membership request."""
842+
# TODO: Implement me
843+
pass
844+
845+
@unit_of_work()
846+
def decline_membership_request(self, identity, request_id, uow=None):
847+
"""Decline membership request."""
848+
# TODO: Implement me
849+
pass

invenio_communities/permissions.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -193,13 +193,14 @@ class CommunityPermissionPolicy(BasePermissionPolicy):
193193
IfPolicyClosed(
194194
"member_policy",
195195
then_=[Disable()],
196-
else_=[AuthenticatedButNotCommunityMembers()]
197-
)
196+
else_=[AuthenticatedButNotCommunityMembers()],
197+
),
198198
],
199-
else_=[Disable()]
199+
else_=[Disable()],
200200
),
201201
]
202202

203+
203204
def can_perform_action(community, context):
204205
"""Check if the given action is available on the request."""
205206
action = context.get("action")

setup.cfg

+1-2
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ invenio_requests.entity_resolvers =
8282
invenio_requests.types =
8383
community_invitation = invenio_communities.members.services.request:CommunityInvitation
8484
subcommunity = invenio_communities.subcommunities.services.request:subcommunity_request_type
85+
membership_request_request_type = invenio_communities.members.services.request:MembershipRequestRequestType
8586
invenio_i18n.translations =
8687
messages = invenio_communities
8788
invenio_administration.views =
@@ -96,8 +97,6 @@ invenio_base.finalize_app =
9697
invenio_base.api_finalize_app =
9798
invenio_communities = invenio_communities.ext:api_finalize_app
9899

99-
100-
101100
[build_sphinx]
102101
source-dir = docs/
103102
build-dir = docs/_build

tests/conftest.py

+41
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ def app_config(app_config):
143143
# When testing unverified users, there is a "unverified_user" fixture for that purpose.
144144
app_config["ACCOUNTS_DEFAULT_USERS_VERIFIED"] = True
145145

146+
app_config["COMMUNITIES_ALLOW_MEMBERSHIP_REQUESTS"] = True
147+
146148
return app_config
147149

148150

@@ -361,6 +363,45 @@ def new_user(UserFixture, app, database):
361363
return u
362364

363365

366+
@pytest.fixture(scope="function")
367+
def create_user(UserFixture, app, db):
368+
"""Create user factory fixture.
369+
370+
It's function scope is key here as it guarantees a user devoid of any prior which
371+
is essential for many tests.
372+
"""
373+
374+
def _create_user(data):
375+
"""Create user."""
376+
default_data = dict(
377+
378+
password="user",
379+
username="user",
380+
user_profile={
381+
"full_name": "Created User",
382+
"affiliations": "CERN",
383+
},
384+
preferences={
385+
"visibility": "public",
386+
"email_visibility": "restricted",
387+
"notifications": {
388+
"enabled": True,
389+
},
390+
},
391+
active=True,
392+
confirmed=True,
393+
)
394+
actual_data = dict(default_data, **data)
395+
u = UserFixture(**actual_data)
396+
u.create(app, db)
397+
current_users_service.indexer.process_bulk_queue()
398+
current_users_service.record_cls.index.refresh()
399+
db.session.commit()
400+
return u
401+
402+
return _create_user
403+
404+
364405
#
365406
# Communities
366407
#

0 commit comments

Comments
 (0)