Skip to content

Commit 91cd841

Browse files
committed
resources: [inveniosoftware#855] 5) (wait for decision flow) add GET membership-requests
1 parent dbf1ce4 commit 91cd841

File tree

13 files changed

+400
-9
lines changed

13 files changed

+400
-9
lines changed

Diff for: invenio_communities/config.py

+26
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"invitations": "/communities/<pid_value>/invitations",
3131
"about": "/communities/<pid_value>/about",
3232
"curation_policy": "/communities/<pid_value>/curation-policy",
33+
"membership_requests": "/communities/<pid_value>/membership-requests"
3334
}
3435

3536
"""Communities ui endpoints."""
@@ -203,6 +204,31 @@
203204
}
204205
"""Definitions of available record sort options."""
205206

207+
COMMUNITIES_MEMBERSHIP_REQUESTS_SEARCH = {
208+
"facets": ["type", "status"],
209+
"sort": ["bestmatch", "name", "newest", "oldest"],
210+
}
211+
"""Community membership requests search configuration."""
212+
213+
COMMUNITIES_MEMBERSHIP_REQUESTS_SORT_OPTIONS = {
214+
"bestmatch": dict(
215+
title=_("Best match"),
216+
fields=["_score"], # ES defaults to desc on `_score` field
217+
),
218+
"name": dict(
219+
title=_("Name"),
220+
fields=["user.profile.full_name.keyword"],
221+
),
222+
"newest": dict(
223+
title=_("Newest"),
224+
fields=["-created"],
225+
),
226+
"oldest": dict(
227+
title=_("Oldest"),
228+
fields=["created"],
229+
),
230+
}
231+
"""Available membership requests sort options."""
206232

207233
COMMUNITIES_INVITATIONS_EXPIRES_IN = timedelta(days=30)
208234
""""Default amount of time before an invitation expires."""

Diff for: invenio_communities/members/records/api.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,10 @@ class MemberMixin:
4343
"""The data-layer id of the user (or None)."""
4444

4545
group_id = ModelField("group_id")
46-
"""The data-layer id of the user (or None)."""
46+
"""The data-layer id of the group (or None)."""
4747

4848
request_id = ModelField("request_id")
49-
"""The data-layer id of the user (or None)."""
49+
"""The data-layer id of the request (or None)."""
5050

5151
role = ModelField("role")
5252
"""The role of the entity."""

Diff for: invenio_communities/members/resources/resource.py

+15
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ def create_url_rules(self):
3636
route("PUT", routes["invitations"], self.update_invitations),
3737
route("GET", routes["invitations"], self.search_invitations),
3838
route("POST", routes["membership_requests"], self.request_membership),
39+
route("GET", routes["membership_requests"],
40+
self.search_membership_requests),
3941
]
4042

4143
@request_view_args
@@ -144,3 +146,16 @@ def delete(self):
144146
data=resource_requestctx.data,
145147
)
146148
return "", 204
149+
150+
@request_view_args
151+
@request_search_args
152+
@response_handler(many=True)
153+
def search_membership_requests(self):
154+
"""Perform a search over the membership requests."""
155+
hits = self.service.search_membership_requests(
156+
g.identity,
157+
resource_requestctx.view_args["pid_value"],
158+
params=resource_requestctx.args,
159+
search_preference=search_preference(),
160+
)
161+
return hits.to_dict(), 200

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

+7-3
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from . import facets
3030
from .components import CommunityMemberCachingComponent
3131
from .schemas import MemberEntitySchema
32+
from .links import LinksForActionsOfMember, LinksForRequestActionsOfMember
3233

3334

3435
class PublicSearchOptions(SearchOptions):
@@ -182,11 +183,14 @@ class MemberServiceConfig(RecordServiceConfig, ConfiguratorMixin):
182183
search_public = PublicSearchOptions
183184
search_invitations = InvitationsSearchOptions
184185

185-
# No links
186-
links_item = {}
186+
links_item = {
187+
"actions": LinksForActionsOfMember([
188+
LinksForRequestActionsOfMember("{+api}/requests/{request_id}/actions/{action}"), # noqa
189+
])
190+
}
187191

188192
# ResultList configurations
189-
links_search = pagination_links("{+api}/communities/{community_id}/members{?args*}")
193+
links_search = pagination_links("{+api}/communities/{community_id}/{endpoint}{?args*}") # noqa
190194

191195
# Service components
192196
components = [

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

+228
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2024 Northwestern University.
4+
#
5+
# Invenio-Communities is free software; you can redistribute it and/or modify
6+
# it under the terms of the MIT License; see LICENSE file for more details.
7+
8+
from invenio_records_resources.services.base.links import Link, LinksTemplate
9+
from invenio_requests.customizations import RequestActions
10+
from invenio_requests.proxies import current_requests_service
11+
from invenio_requests.resolvers.registry import ResolverRegistry
12+
from uritemplate import URITemplate
13+
14+
15+
class MemberLinksTemplate(LinksTemplate):
16+
"""A links template that passes the request type in the context.
17+
18+
This template is useful to avoid having to dereference the request type into
19+
members at the DB-level. It's legitimate (for now), because we know what
20+
kind of request type we are dealing with at the service search method (e.g. when
21+
searching for invitations we know the request type of requests associated with
22+
Members is CommunityInvitation).
23+
"""
24+
25+
def __init__(self, links, context=None, request_type=None):
26+
"""Constructor.
27+
28+
:param links: a dict of Links (or objects that have same interface)
29+
:param context: dict of context values
30+
:param request_type: a RequestType
31+
"""
32+
context = context or {}
33+
context["request_type"] = request_type
34+
super().__init__(links, context=context)
35+
36+
37+
class LinksForActionsOfMember:
38+
"""Intermediary template of links.
39+
40+
It responds to the same interface as a `Link`, but is used to dynamically generate
41+
the dict of different possible action links of a Member.
42+
43+
This is part of allowing us to save on extra attributes on the config and condensing
44+
link generation where it belongs to a narrow interface with deep logic.
45+
"""
46+
47+
def __init__(self, links_for_actions):
48+
"""Constructor.
49+
50+
:param links_for_actions: list of Link-like
51+
"""
52+
self._links_for_actions = links_for_actions
53+
54+
def expand(self, obj, context):
55+
"""Expand all the action link templates.
56+
57+
:param obj: api.Member
58+
:param context: dict of contextual values
59+
60+
:return: dict of links
61+
"""
62+
links = {}
63+
for link in self._links_for_actions:
64+
if link.should_render(obj, context):
65+
link.expand(obj, context, into=links)
66+
return links
67+
68+
def should_render(self, obj, context):
69+
"""Conforms to `Link` interface but always renders.
70+
71+
Consequence: will always render the key even if no action links should render
72+
i.e. if empty dict. This is probably simpler for frontend too.
73+
"""
74+
return True
75+
76+
77+
class RequestLike:
78+
"""A Request like object for interface purposes."""
79+
80+
def __init__(self, obj, context):
81+
"""Constructor.
82+
83+
May raise IndexError (and that's Ok - should be handled).
84+
85+
:param obj: api.Member
86+
:param context: dict of context values
87+
"""
88+
self.id = obj.request_id
89+
self.type = context["request_type"]
90+
request_relation = obj["request"]
91+
self.status = request_relation["status"]
92+
self.created_by = self._get_created_by(obj)
93+
self.receiver = self._get_receiver(obj)
94+
95+
def _get_created_by(self, obj):
96+
"""Get the created_by field's proxy.
97+
98+
Assigns a Proxy to `created_by` based on the type of request
99+
associated with obj.
100+
101+
Warning: constructor method: not full self yet.
102+
103+
:param obj: api.Member
104+
"""
105+
# This assertion is to alert us developers if the associated
106+
# ref_types certainty of only 1 type changes. If it does, we need to rethink
107+
# things.
108+
assert 1 == len(self.type.allowed_creator_ref_types)
109+
110+
creator_ref_type = self.type.allowed_creator_ref_types[0]
111+
return self._get_proxy_by_ref_type(creator_ref_type, obj)
112+
113+
114+
def _get_receiver(self, obj):
115+
"""Set the receiver field.
116+
117+
Assigns a Proxy to `receiver` based on the type of request associated with obj.
118+
119+
Warning: constructor method: not full self yet.
120+
121+
:param obj: api.Member
122+
"""
123+
# This assertion is to alert us developers if the associated
124+
# ref_types certainty of only 1 type changes. If it does, we need to rethink
125+
# things.
126+
assert 1 == len(self.type.allowed_receiver_ref_types)
127+
128+
receiver_ref_type = self.type.allowed_receiver_ref_types[0]
129+
return self._get_proxy_by_ref_type(receiver_ref_type, obj)
130+
131+
# assert 1 == len(self.type.allowed_topic_ref_types)
132+
133+
def _get_proxy_by_ref_type(self, ref_type, obj):
134+
"""Returns proxy for given ref type.
135+
136+
:param ref_type: string key of reference type
137+
:param obj: api.Member
138+
"""
139+
if ref_type == "community":
140+
# This *creates* an entity proxy contrary to the name
141+
return ResolverRegistry.resolve_entity_proxy(
142+
{"community": obj.community_id}
143+
)
144+
elif ref_type == "user":
145+
# This *creates* an entity proxy contrary to the name
146+
return ResolverRegistry.resolve_entity_proxy(
147+
{"user": obj.user_id}
148+
)
149+
else:
150+
# again mostly for developers to be alerted
151+
raise Exception("ref_type is unknown!")
152+
153+
154+
class LinksForRequestActionsOfMember:
155+
"""Links specifically for the request related to the Member."""
156+
157+
def __init__(self, uritemplate):
158+
"""Constructor."""
159+
# Only accepting the uritemplate as arg for "consistency" with other Link-likes
160+
# so that URLs can be perused on skimming a service's config.
161+
self._uritemplate = uritemplate
162+
163+
def should_render(self, obj, context):
164+
"""Render based on if there is an associated request at all.
165+
166+
:param obj: api.Member
167+
:param context: dict of context values
168+
"""
169+
try:
170+
RequestLike(obj, context)
171+
return True
172+
except KeyError:
173+
return False
174+
175+
def expand(self, obj, context, into):
176+
"""Expand all the member's request's action links templates.
177+
178+
:param obj: api.Member
179+
:param context: dict of context values
180+
:param into: dict of resulting links
181+
"""
182+
# Know that we can get a RequestLike without issue at this point since
183+
# should_render has returned True.
184+
request_like = RequestLike(obj, context)
185+
186+
for action in request_like.type.available_actions:
187+
link = LinkForRequestAction(self._uritemplate, action)
188+
if link.should_render(request_like, context):
189+
into[action] = link.expand(request_like, context)
190+
191+
192+
class LinkForRequestAction(Link):
193+
"""Link for the action of a request."""
194+
195+
def __init__(self, uritemplate, action):
196+
"""Constructor."""
197+
self._uritemplate = URITemplate(uritemplate)
198+
self.action = action
199+
200+
def _vars_func(self, request, vars):
201+
"""Inject the passed vars (context) with items specific to this Link.
202+
203+
`vars` has been copied at this point and therefore can be
204+
modified in-place.
205+
206+
:param request: RequestLike
207+
:param vars: dict of contextual values
208+
"""
209+
vars.update(
210+
{
211+
"action": self.action,
212+
"request_id": request.id
213+
}
214+
)
215+
216+
def should_render(self, request, context):
217+
"""Determine if the link should render."""
218+
action_for_execute = self.action
219+
action_for_permission = f"action_{action_for_execute}"
220+
identity = context.get("identity")
221+
permission = current_requests_service.permission_policy(
222+
action_for_permission,
223+
request=request,
224+
)
225+
return (
226+
RequestActions.can_execute(request, action_for_execute)
227+
and permission.allows(identity)
228+
)

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

+26
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,19 @@ def execute(self, identity, uow):
145145
super().execute(identity, uow)
146146

147147

148+
class AcceptMembershipRequestAction(actions.AcceptAction):
149+
def execute(self, identity, uow):
150+
"""Execute action."""
151+
# TODO: Decision flow: Implement me
152+
pass
153+
154+
155+
class DeclineMembershipRequestAction(actions.DeclineAction):
156+
def execute(self, identity, uow):
157+
"""Execute action."""
158+
# TODO: Decision flow: Implement me
159+
pass
160+
148161
class MembershipRequestRequestType(RequestType):
149162
"""Request type for membership requests."""
150163

@@ -155,10 +168,23 @@ class MembershipRequestRequestType(RequestType):
155168
available_actions = {
156169
"create": actions.CreateAndSubmitAction,
157170
"cancel": CancelMembershipRequestAction,
171+
"accept": AcceptMembershipRequestAction,
172+
"decline": DeclineMembershipRequestAction,
158173
}
159174

160175
creator_can_be_none = False
161176
topic_can_be_none = False
162177
allowed_creator_ref_types = ["user"]
163178
allowed_receiver_ref_types = ["community"]
164179
allowed_topic_ref_types = ["community"]
180+
181+
# This indicates what roles an identity must have within the receiving community
182+
# in order to accept/decline. Although a pattern, it's ultimately a more hidden way
183+
# to define permission than a permission policy. It repeats concept because it
184+
# is subservient to the Request permission policy abstractions.
185+
needs_context = {
186+
"community_roles": [
187+
"owner",
188+
"manager",
189+
]
190+
}

Diff for: invenio_communities/permissions.py

+1
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ class CommunityPermissionPolicy(BasePermissionPolicy):
199199
else_=[Disable()],
200200
),
201201
]
202+
can_search_membership_requests = [CommunityManagers(), SystemProcess()]
202203

203204

204205
def can_perform_action(community, context):

Diff for: invenio_communities/templates/semantic-ui/invenio_communities/details/members/base.html

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
{%- set menu_items = {
1919
'members': (_('Members'), url_for('invenio_communities.members', pid_value=community.slug), permissions.can_read),
2020
'invitations': (_('Invitations'), url_for('invenio_communities.invitations', pid_value=community.slug), permissions.can_search_invites),
21+
'membership_requests': (_('Membership Requests'), url_for('invenio_communities.membership_requests', pid_value=community.slug), permissions.can_search_membership_requests),
2122
} %}
2223
<div class="three wide computer sixteen wide tablet sixteen wide mobile column">
2324
<div class="ui vertical computer horizontal tablet horizontal mobile menu theme-primary-menu">

0 commit comments

Comments
 (0)