Skip to content

Commit b3e3cfd

Browse files
committed
Replace Group.members and User.members with memberships
1 parent eca9ff7 commit b3e3cfd

20 files changed

Lines changed: 259 additions & 88 deletions

File tree

h/models/group.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
import slugify
66
import sqlalchemy as sa
77
from sqlalchemy.dialects.postgresql import JSONB
8+
from sqlalchemy.orm import relationship
89

910
from h import pubid
1011
from h.db import Base, mixins
12+
from h.models.user import User
1113
from h.util.group import split_groupid
1214

1315
GROUP_NAME_MIN_LENGTH = 3
@@ -49,14 +51,19 @@ class GroupMembership(Base):
4951
__table_args__ = (sa.UniqueConstraint("user_id", "group_id"),)
5052

5153
id = sa.Column("id", sa.Integer, autoincrement=True, primary_key=True)
54+
5255
user_id = sa.Column("user_id", sa.Integer, sa.ForeignKey("user.id"), nullable=False)
56+
user = relationship("User", back_populates="memberships")
57+
5358
group_id = sa.Column(
5459
"group_id",
5560
sa.Integer,
5661
sa.ForeignKey("group.id", ondelete="cascade"),
5762
nullable=False,
5863
index=True,
5964
)
65+
group = relationship("Group", back_populates="memberships")
66+
6067
roles = sa.Column(
6168
JSONB,
6269
sa.CheckConstraint(
@@ -159,12 +166,31 @@ def groupid(self, value):
159166
self.authority_provided_id = groupid_parts["authority_provided_id"]
160167
self.authority = groupid_parts["authority"]
161168

162-
# Group membership
163-
members = sa.orm.relationship(
164-
"User",
165-
secondary="user_group",
166-
backref=sa.orm.backref("groups", order_by="Group.name"),
167-
)
169+
memberships = sa.orm.relationship("GroupMembership", back_populates="group")
170+
171+
@property
172+
def members(self) -> tuple[User, ...]:
173+
"""
174+
Return a tuple of this group's members.
175+
176+
This is a convenience property for when you want to access a group's
177+
members (User objects) rather than its memberships (GroupMembership
178+
objects).
179+
180+
This is not an SQLAlchemy relationship! SQLAlchemy emits a warning if
181+
you try to have both Group.memberships and a Group.members
182+
relationships at the same time because it can result in reads returning
183+
conflicting data and in writes causing integrity errors or unexpected
184+
inserts or deletes. See:
185+
186+
https://docs.sqlalchemy.org/en/20/orm/basic_relationships.html#combining-association-object-with-many-to-many-access-patterns
187+
188+
Since this is just a normal Python property setting or mutating it
189+
(e.g. `group.members = [...]` or `group.members.append(...)`) wouldn't
190+
be registered with SQLAlchemy and the changes wouldn't be saved to the
191+
DB. So this is a read-only property that returns an immutable tuple.
192+
"""
193+
return tuple(membership.user for membership in self.memberships)
168194

169195
scopes = sa.orm.relationship(
170196
"GroupScope", backref="group", cascade="all, delete-orphan"

h/models/user.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import datetime
22
import re
3+
from typing import TYPE_CHECKING
34

45
import sqlalchemy as sa
56
from sqlalchemy.ext.declarative import declared_attr
@@ -9,6 +10,10 @@
910
from h.exceptions import InvalidUserId
1011
from h.util.user import format_userid, split_user
1112

13+
if TYPE_CHECKING:
14+
from models.group import Group # pragma: nocover
15+
16+
1217
USERNAME_MIN_LENGTH = 3
1318
USERNAME_MAX_LENGTH = 30
1419
USERNAME_PATTERN = "(?i)^[A-Z0-9._]+$"
@@ -290,6 +295,32 @@ def activate(self):
290295

291296
tokens = sa.orm.relationship("Token", back_populates="user")
292297

298+
memberships = sa.orm.relationship("GroupMembership", back_populates="user")
299+
300+
@property
301+
def groups(self) -> tuple["Group", ...]:
302+
"""
303+
Return a tuple of this user's groups.
304+
305+
This is a convenience property for when you want to access a user's
306+
groups (Group objects) rather than its memberships (GroupMembership
307+
objects).
308+
309+
This is not an SQLAlchemy relationship! SQLAlchemy emits a warning if
310+
you try to have both User.memberships and a User.groups relationships
311+
at the same time because it can result in reads returning conflicting
312+
data and in writes causing integrity errors or unexpected inserts or
313+
deletes. See:
314+
315+
https://docs.sqlalchemy.org/en/20/orm/basic_relationships.html#combining-association-object-with-many-to-many-access-patterns
316+
317+
Since this is just a normal Python property setting or mutating it
318+
(e.g. `user.groups = [...]` or `user.groups.append(...)`) wouldn't
319+
be registered with SQLAlchemy and the changes wouldn't be saved to the
320+
DB. So this is a read-only property that returns an immutable tuple.
321+
"""
322+
return tuple(membership.group for membership in self.memberships)
323+
293324
@sa.orm.validates("email")
294325
def validate_email(self, _key, email):
295326
if email is None:

h/services/group_create.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,7 @@ def _create(self, name, userid, type_flags, scopes, **kwargs):
126126
# self.publish() or `return group`.
127127
self.db.flush()
128128

129-
self.db.add(
130-
GroupMembership(user_id=creator.id, group_id=group.id, roles=["owner"])
131-
)
129+
group.memberships.append(GroupMembership(user=group.creator, roles=["owner"]))
132130
self.publish("group-join", group.pubid, group.creator.userid)
133131

134132
return group

h/services/group_members.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from functools import partial
22

33
from h import session
4+
from h.models import GroupMembership
45

56

67
class GroupMembersService:
@@ -60,18 +61,25 @@ def member_join(self, group, userid):
6061
if user in group.members:
6162
return
6263

63-
group.members.append(user)
64+
group.memberships.append(GroupMembership(user=user))
6465

6566
self.publish("group-join", group.pubid, userid)
6667

6768
def member_leave(self, group, userid):
6869
"""Remove `userid` from the member list of `group`."""
6970
user = self.user_fetcher(userid)
7071

71-
if user not in group.members:
72+
matching_memberships = [
73+
membership for membership in group.memberships if membership.user == user
74+
]
75+
76+
if not matching_memberships:
7277
return
7378

74-
group.members.remove(user)
79+
for membership in matching_memberships:
80+
self.db.delete(membership)
81+
group.memberships.remove(membership)
82+
user.memberships.remove(membership)
7583

7684
self.publish("group-leave", group.pubid, userid)
7785

tests/common/factories/group.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ class Meta:
2020
joinable_by = JoinableBy.authority
2121
readable_by = ReadableBy.members
2222
writeable_by = WriteableBy.members
23-
members = factory.LazyFunction(list)
2423
authority_provided_id = Faker("hexify", text="^" * 30)
2524
enforce_scope = True
2625

tests/functional/api/bulk/annotation_test.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import pytest
44

5+
from h.models import GroupMembership
6+
57

68
@pytest.mark.usefixtures("with_clean_db")
79
class TestBulkAnnotation:
@@ -23,7 +25,7 @@ def test_it_accepts_a_valid_request(self, make_request, factories):
2325
group = factories.Group(
2426
authority="lms.hypothes.is",
2527
authority_provided_id="1234567890",
26-
members=[user],
28+
memberships=[GroupMembership(user=user)],
2729
)
2830
annotation_slim = factories.AnnotationSlim(
2931
user=user,

tests/functional/api/groups/members_test.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,14 @@ class TestReadMembers:
1010
def test_it_returns_list_of_members_for_restricted_group_without_authn(
1111
self, app, factories, db_session
1212
):
13-
group = factories.RestrictedGroup()
14-
group.members = [factories.User(), factories.User(), factories.User()]
13+
group = factories.RestrictedGroup(
14+
memberships=[
15+
GroupMembership(user=user)
16+
for user in factories.User.create_batch(size=3)
17+
]
18+
)
1519
db_session.commit()
20+
1621
res = app.get("/api/groups/{pubid}/members".format(pubid=group.pubid))
1722

1823
assert res.status_code == 200
@@ -22,7 +27,9 @@ def test_it_returns_list_of_members_if_user_has_access_to_private_group(
2227
self, app, factories, db_session, group, user_with_token, token_auth_header
2328
):
2429
user, _ = user_with_token
25-
group.members.extend([user, factories.User()])
30+
group.memberships.extend(
31+
[GroupMembership(user=user), GroupMembership(user=factories.User())]
32+
)
2633
db_session.commit()
2734

2835
res = app.get(
@@ -251,7 +258,7 @@ def third_party_group(db_session, factories):
251258
@pytest.fixture
252259
def group_member(group, db_session, factories):
253260
user = factories.User()
254-
group.members.append(user)
261+
group.memberships.append(GroupMembership(user=user))
255262
db_session.commit()
256263
return user
257264

tests/functional/api/groups/read_test.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,8 @@ def test_it_returns_private_groups_along_with_world_groups(
2121
self, app, factories, db_session, user_with_token, token_auth_header
2222
):
2323
user, _ = user_with_token
24-
group1 = factories.Group()
25-
db_session.add(GroupMembership(group_id=group1.id, user_id=user.id))
26-
group2 = factories.Group()
27-
db_session.add(GroupMembership(group_id=group2.id, user_id=user.id))
24+
group1 = factories.Group(memberships=[GroupMembership(user=user)])
25+
group2 = factories.Group(memberships=[GroupMembership(user=user)])
2826
db_session.commit()
2927

3028
res = app.get("/api/groups", headers=token_auth_header)
@@ -38,8 +36,9 @@ def test_it_overrides_authority_param_with_user_authority(
3836
self, app, factories, db_session, user_with_token, token_auth_header
3937
):
4038
user, _ = user_with_token
41-
group1 = factories.Group(authority=user.authority)
42-
db_session.add(GroupMembership(group_id=group1.id, user_id=user.id))
39+
group1 = factories.Group(
40+
authority=user.authority, memberships=[GroupMembership(user=user)]
41+
)
4342
db_session.commit()
4443

4544
res = app.get("/api/groups?authority=whatever.com", headers=token_auth_header)
@@ -94,8 +93,7 @@ def test_it_returns_http_200_for_private_group_with_creator_authentication(
9493
self, app, user_with_token, token_auth_header, factories, db_session
9594
):
9695
user, _ = user_with_token
97-
group = factories.Group(creator=user)
98-
db_session.add(GroupMembership(group_id=group.id, user_id=user.id))
96+
group = factories.Group(creator=user, memberships=[GroupMembership(user=user)])
9997
db_session.commit()
10098

10199
res = app.get(
@@ -109,7 +107,7 @@ def test_it_returns_http_200_for_private_group_with_member_authentication(
109107
):
110108
user, _ = user_with_token
111109
group = factories.Group()
112-
group.members.append(user)
110+
group.memberships.append(GroupMembership(user=user))
113111
db_session.commit()
114112

115113
res = app.get(

tests/functional/api/profile_test.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import pytest
22

3+
from h.models import GroupMembership
4+
35

46
class TestGetProfile:
57
def test_it_returns_profile_with_single_group_when_not_authd(self, app):
@@ -147,8 +149,9 @@ def groups(factories):
147149

148150
@pytest.fixture
149151
def user(groups, db_session, factories):
150-
user = factories.User()
151-
user.groups = groups
152+
user = factories.User(
153+
memberships=[GroupMembership(group=group) for group in groups]
154+
)
152155
db_session.commit()
153156
return user
154157

tests/unit/h/jinja2_extensions/navbar_data_test.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from h_matchers import Any
55

66
from h.jinja_extensions.navbar_data import navbar_data
7+
from h.models import GroupMembership
78

89

910
class TestNavbarData:
@@ -53,9 +54,9 @@ def test_it(self, pyramid_request, user):
5354
def test_it_with_a_group_with_no_creator(
5455
self, pyramid_request, user, factories, group_list_service
5556
):
56-
user.groups = group_list_service.associated_groups.return_value = [
57-
factories.Group(creator=None)
58-
]
57+
group = factories.Group(creator=None)
58+
group_list_service.associated_groups.return_value = [group]
59+
user.memberships = [GroupMembership(group=group, user=user)]
5960
pyramid_request.user = user
6061

6162
result = navbar_data(pyramid_request)
@@ -111,9 +112,15 @@ def test_search_url(self, pyramid_request, matched_route, matchdict, search_url)
111112
assert result["search_url"] == search_url
112113

113114
@pytest.fixture
114-
def user(self, factories):
115+
def user(self, db_session, factories):
115116
user = factories.User()
116-
user.groups = [factories.Group(creator=user), factories.Group()]
117+
118+
with db_session.no_autoflush:
119+
user.memberships = [
120+
GroupMembership(group=group)
121+
for group in [factories.Group(creator=user), factories.Group()]
122+
]
123+
117124
return user
118125

119126
@pytest.fixture(autouse=True)

0 commit comments

Comments
 (0)