Skip to content

Commit

Permalink
resources: [inveniosoftware#855] add POST request_membership
Browse files Browse the repository at this point in the history
  • Loading branch information
fenekku committed May 2, 2024
1 parent b3737d0 commit e083648
Show file tree
Hide file tree
Showing 7 changed files with 249 additions and 6 deletions.
3 changes: 2 additions & 1 deletion invenio_communities/members/resources/config.py
Original file line number Diff line number Diff line change
@@ -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.
#
Expand Down Expand Up @@ -29,6 +29,7 @@ class MemberResourceConfig(RecordResourceConfig):
"members": "/communities/<pid_value>/members",
"publicmembers": "/communities/<pid_value>/members/public",
"invitations": "/communities/<pid_value>/invitations",
"membership_requests": "/communities/<pid_value>/membership-requests",
}
request_view_args = {
"pid_value": ma.fields.UUID(),
Expand Down
14 changes: 13 additions & 1 deletion invenio_communities/members/resources/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions invenio_communities/members/services/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
6 changes: 6 additions & 0 deletions invenio_communities/members/services/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
111 changes: 110 additions & 1 deletion invenio_communities/members/services/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,15 @@
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,
InvitationDumpSchema,
InviteBulkSchema,
MemberDumpSchema,
PublicDumpSchema,
RequestMembershipSchema,
UpdateBulkSchema,
)

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -734,3 +740,106 @@ 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):
# TODO: Implement me
pass

def search_membership_requests(self):
# TODO: Implement me
pass

@unit_of_work()
def accept_membership_request(self, identity, request_id, uow=None):
# TODO: Implement me
pass

@unit_of_work()
def decline_membership_request(self, identity, request_id, uow=None):
# TODO: Implement me
pass
3 changes: 1 addition & 2 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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
Expand Down
100 changes: 99 additions & 1 deletion tests/members/test_members_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


#
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -357,3 +358,100 @@ 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
#

# The `new_user`` module fixture leaks identity across tests, so a pure new user for
# each following test is the way to go.
@pytest.fixture()
def create_user(UserFixture, app, db):
"""Create user factory fixture."""

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


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
assert False


def test_error_handling_for_membership_requests(client, headers, community_id, owner, new_user_data, db):
# error handling registered
# - permission handling registered
# - duplicate handling registered
assert False


# is cancelling request purview of this?

# TODO: search membership requests
# def test_get_membership_requests(client):
# 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"]

0 comments on commit e083648

Please sign in to comment.