Skip to content

Commit e083648

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

File tree

7 files changed

+249
-6
lines changed

7 files changed

+249
-6
lines changed

Diff for: 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(),

Diff for: 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

Diff for: 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"]

Diff for: 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
#

Diff for: invenio_communities/members/services/service.py

+110-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,106 @@ 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+
# TODO: Implement me
831+
pass
832+
833+
def search_membership_requests(self):
834+
# TODO: Implement me
835+
pass
836+
837+
@unit_of_work()
838+
def accept_membership_request(self, identity, request_id, uow=None):
839+
# TODO: Implement me
840+
pass
841+
842+
@unit_of_work()
843+
def decline_membership_request(self, identity, request_id, uow=None):
844+
# TODO: Implement me
845+
pass

Diff for: setup.cfg

+1-2
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ invenio_requests.entity_resolvers =
8080
communities = invenio_communities.communities.entity_resolvers:CommunityResolver
8181
invenio_requests.types =
8282
community_invitation = invenio_communities.members.services.request:CommunityInvitation
83+
membership_request_request_type = invenio_communities.members.services.request:MembershipRequestRequestType
8384
invenio_i18n.translations =
8485
messages = invenio_communities
8586
invenio_administration.views =
@@ -94,8 +95,6 @@ invenio_base.finalize_app =
9495
invenio_base.api_finalize_app =
9596
invenio_communities = invenio_communities.ext:api_finalize_app
9697

97-
98-
9998
[build_sphinx]
10099
source-dir = docs/
101100
build-dir = docs/_build

Diff for: tests/members/test_members_resource.py

+99-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import pytest
1212
from invenio_access.permissions import system_identity
1313
from invenio_requests.records.api import RequestEvent
14+
from invenio_users_resources.proxies import current_users_service
1415

1516

1617
#
@@ -134,7 +135,7 @@ def test_invite_deny(client, headers, community_id, new_user, new_user_data, db)
134135
#
135136
# Update
136137
#
137-
def test_update(client, headers, community_id, owner, public_reader):
138+
def test_update(client, headers, community_id, owner, public_reader, db):
138139
"""Test update of members."""
139140
client = owner.login(client)
140141
data = {
@@ -357,3 +358,100 @@ def test_search_invitation(
357358
# TODO: facet by role, facet by visibility, define sorts.
358359
# TODO: same user can be invited to two different communities
359360
# TODO: same user/group can be added to two different communities
361+
362+
363+
#
364+
# Membership request
365+
#
366+
367+
# The `new_user`` module fixture leaks identity across tests, so a pure new user for
368+
# each following test is the way to go.
369+
@pytest.fixture()
370+
def create_user(UserFixture, app, db):
371+
"""Create user factory fixture."""
372+
373+
def _create_user(data):
374+
"""Create user."""
375+
default_data = dict(
376+
377+
password="user",
378+
username="user",
379+
user_profile={
380+
"full_name": "Created User",
381+
"affiliations": "CERN",
382+
},
383+
preferences={
384+
"visibility": "public",
385+
"email_visibility": "restricted",
386+
"notifications": {
387+
"enabled": True,
388+
},
389+
},
390+
active=True,
391+
confirmed=True,
392+
)
393+
actual_data = dict(default_data, **data)
394+
u = UserFixture(**actual_data)
395+
u.create(app, db)
396+
current_users_service.indexer.process_bulk_queue()
397+
current_users_service.record_cls.index.refresh()
398+
db.session.commit()
399+
return u
400+
401+
return _create_user
402+
403+
404+
def test_post_membership_requests(app, client, headers, community_id, create_user, db):
405+
user = create_user({"email": "[email protected]", "username": "user_foo"})
406+
client = user.login(client)
407+
408+
# Post membership request
409+
r = client.post(
410+
f"/communities/{community_id}/membership-requests",
411+
headers=headers,
412+
json={"message": "Can I join the club?"},
413+
)
414+
assert 201 == r.status_code
415+
416+
RequestEvent.index.refresh()
417+
418+
# Get links to check
419+
url_of_request = r.json["links"]["self"].replace(app.config["SITE_API_URL"], "")
420+
url_of_timeline = r.json["links"]["timeline"].replace(
421+
app.config["SITE_API_URL"],
422+
"",
423+
)
424+
425+
# Check the request
426+
r = client.get(url_of_request, headers=headers)
427+
assert 200 == r.status_code
428+
assert 'Request to join "My Community"' in r.json["title"]
429+
430+
# Check the timeline
431+
r = client.get(url_of_timeline, headers=headers)
432+
assert 200 == r.status_code
433+
assert 1 == r.json["hits"]["total"]
434+
msg = r.json["hits"]["hits"][0]["payload"]["content"]
435+
assert 'Can I join the club?' == msg
436+
437+
438+
def test_put_membership_requests(client, headers, community_id, owner, new_user_data, db):
439+
# update membership request
440+
assert False
441+
442+
443+
def test_error_handling_for_membership_requests(client, headers, community_id, owner, new_user_data, db):
444+
# error handling registered
445+
# - permission handling registered
446+
# - duplicate handling registered
447+
assert False
448+
449+
450+
# is cancelling request purview of this?
451+
452+
# TODO: search membership requests
453+
# def test_get_membership_requests(client):
454+
# RequestEvent.index.refresh()
455+
# r = client.get(f"/communities/{community_id}/membership-requests", headers=headers)
456+
# assert r.status_code == 200
457+
# request_id = r.json["hits"]["hits"][0]["request"]["id"]

0 commit comments

Comments
 (0)