Skip to content

Commit e569e19

Browse files
committed
Show hidden annotations to group moderators
1 parent a5f1dc6 commit e569e19

File tree

5 files changed

+166
-161
lines changed

5 files changed

+166
-161
lines changed

h/search/core.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,8 @@ def __init__(
5050
query.Limiter(),
5151
query.DeletedFilter(),
5252
query.AuthFilter(request),
53-
query.GroupFilter(request),
53+
query.GroupAndModerationFilter(request),
5454
query.UserFilter(),
55-
query.HiddenFilter(request),
5655
query.AnyMatcher(),
5756
query.TagsMatcher(),
5857
query.UriCombinedWildcardFilter(

h/search/query.py

Lines changed: 51 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from h import storage
99
from h.search.util import add_default_scheme, wildcard_uri_is_valid
10+
from h.security.permissions import Permission
1011
from h.util import uri
1112

1213
LIMIT_DEFAULT = 20
@@ -207,23 +208,62 @@ def __call__(self, search, params): # noqa: ARG002
207208
return search.filter("term", shared=True)
208209

209210

210-
class GroupFilter:
211-
"""
212-
Filter that limits which groups annotations are returned from.
213-
214-
This excludes annotations from groups that the user is not authorized to
215-
read or which are explicitly excluded by the search query.
216-
"""
217-
211+
class GroupAndModerationFilter:
218212
def __init__(self, request):
219213
self.user = request.user
220214
self.group_service = request.find_service(name="group")
221215

222216
def __call__(self, search, params):
223-
# Remove parameter if passed, preventing it being passed to default query
224217
group_ids = popall(params, "group") or None
225-
groups = self.group_service.groupids_readable_by(self.user, group_ids)
226-
return search.filter("terms", group=groups)
218+
groups = self.group_service.groups_readable_by(self.user, group_ids)
219+
220+
not_nipsa = ~Q("term", nipsa=True)
221+
not_hidden = ~Q("term", hidden=True)
222+
223+
if not self.user:
224+
# If there not a logged in user
225+
if groups:
226+
# Apply the group filter as it is
227+
search = search.filter("terms", group=[g.pubid for g in groups])
228+
# And don't show any hidden or NIPSA'd annotations
229+
return search.filter(not_nipsa & not_hidden)
230+
231+
if not groups:
232+
# If the user is logged in, hide hidden annos and NIPSA'd annos except the ones authored by the user
233+
return search.filter(
234+
(not_hidden & not_nipsa) | Q("term", user=self.user.userid.lower())
235+
)
236+
237+
query_clauses = []
238+
239+
from h.security import Identity, identity_permits
240+
from h.traversal import GroupContext
241+
242+
# If t he user is logged in and we are filtering by groups
243+
# we'll check for each group if we are a moderator
244+
for group in groups:
245+
user_is_moderator = identity_permits(
246+
identity=Identity.from_models(user=self.user),
247+
context=GroupContext(group),
248+
permission=Permission.Group.MODERATE,
249+
)
250+
if user_is_moderator:
251+
# For modereators:
252+
# - We show all their annos
253+
# - We don't filter out hideden annos
254+
# - We do hide NIPSA'd annos
255+
query_clauses = Q("term", group=group.pubid) & not_nipsa
256+
257+
else:
258+
# For non moderators:
259+
# - We show all their annos
260+
# - We hide hidden annos
261+
# - We hide NIPSA'd annos
262+
query_clauses = Q("term", group=group.pubid) & (
263+
not_hidden & not_nipsa
264+
) | Q("term", user=self.user.userid.lower())
265+
266+
return search.filter(Q("bool", should=query_clauses))
227267

228268

229269
class UriCombinedWildcardFilter:
@@ -347,29 +387,6 @@ def __call__(self, search, _):
347387
return search.exclude("exists", field="deleted")
348388

349389

350-
class HiddenFilter:
351-
"""Return an Elasticsearch filter for filtering out moderated or NIPSA'd annotations."""
352-
353-
def __init__(self, request):
354-
self.group_service = request.find_service(name="group")
355-
self.user = request.user
356-
357-
def __call__(self, search, _):
358-
"""Filter out all hidden and NIPSA'd annotations except the current user's."""
359-
# If any one of these "should" clauses is true then the annotation will
360-
# get through the filter.
361-
should_clauses = [
362-
Q("bool", must_not=[Q("term", nipsa=True), Q("term", hidden=True)])
363-
]
364-
365-
if self.user is not None:
366-
# Always show the logged-in user's annotations even if they have
367-
# been hidden or the user has been NIPSA'd
368-
should_clauses.append(Q("term", user=self.user.userid.lower()))
369-
370-
return search.filter(Q("bool", should=should_clauses))
371-
372-
373390
class AnyMatcher:
374391
"""Match the contents of a selection of fields against the `any` parameter."""
375392

h/services/group.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import sqlalchemy as sa
22
from sqlalchemy import literal_column, select, union
3+
from sqlalchemy.orm import aliased
34

4-
from h.models import Group, GroupMembership
5+
from h.models import Group, GroupMembership, User
56
from h.models.group import ReadableBy
67
from h.util import group as group_util
78

@@ -76,28 +77,26 @@ def filter_by_name(self, name=None):
7677
.order_by(Group.created.desc())
7778
)
7879

79-
def groupids_readable_by(self, user, group_ids=None):
80+
def groups_readable_by(self, user: User | None, group_ids=None) -> list[Group]:
8081
"""
81-
Return a list of pubids for which the user has read access.
82+
Return a list of groups for which the user has read access.
8283
8384
If the passed-in user is ``None``, this returns the list of
8485
world-readable groups.
8586
8687
If `group_ids` is specified, only the subset of groups from that list is
8788
returned. This is more efficient if the caller wants to know which
8889
groups from a specific list are readable by the user.
89-
90-
:type user: `h.models.user.User`
9190
"""
9291
# Very few groups are readable by world, query those separately
93-
readable_by_world_query = select(Group.pubid).where(
92+
readable_by_world_query = select(Group).where(
9493
Group.readable_by == ReadableBy.world
9594
)
9695
query = readable_by_world_query
9796

9897
if user is not None:
9998
user_is_member_query = (
100-
select(Group.pubid)
99+
select(Group)
101100
.join(GroupMembership)
102101
.where(
103102
GroupMembership.user_id == user.id,
@@ -106,14 +105,22 @@ def groupids_readable_by(self, user, group_ids=None):
106105
)
107106
# Union these with the world readable ones
108107
# We wrap a subquery around in case we need to apply the group_ids filter below
109-
query = select(
110-
union(readable_by_world_query, user_is_member_query).subquery()
111-
)
108+
union_groups = union(
109+
readable_by_world_query, user_is_member_query
110+
).subquery()
111+
# We explicitly alias the union to hint SQLAlchemy to fetch Group objects
112+
group_alias = aliased(Group, union_groups)
113+
query = select(group_alias)
112114

113115
if group_ids:
114116
query = query.where(literal_column("pubid").in_(group_ids))
115117

116-
return self.session.scalars(query).all()
118+
return self.session.scalars(query)
119+
120+
def groupids_readable_by(self, user, group_ids=None):
121+
"""Return a list of groupids for which the user has read access."""
122+
123+
return [g.pubid for g in self.groups_readable_by(user, group_ids)]
117124

118125
def groupids_created_by(self, user):
119126
"""

tests/unit/h/search/core_test.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,3 +306,9 @@ def test_only_200_replies_are_included(self, pyramid_request, Annotation):
306306

307307
assert len(result.reply_ids) == 3
308308
assert oldest_reply.id not in result.reply_ids
309+
310+
311+
@pytest.fixture
312+
def group_service(group_service):
313+
group_service.groups_readable_by.return_value = []
314+
return group_service

0 commit comments

Comments
 (0)