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 7, 2024
1 parent b3737d0 commit 66eeb8e
Show file tree
Hide file tree
Showing 10 changed files with 273 additions and 9 deletions.
2 changes: 2 additions & 0 deletions invenio_communities/generators.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ def query_filter(self, **kwargs):
# Community membership generators
#


class AuthenticatedButNotCommunityMembers(Generator):
"""Authenticated user not part of community."""

Expand All @@ -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."""

Expand Down
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
115 changes: 114 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,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
7 changes: 4 additions & 3 deletions invenio_communities/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
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
41 changes: 41 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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="[email protected]",
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
#
Expand Down
Loading

0 comments on commit 66eeb8e

Please sign in to comment.