Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace Group.members and User.members with memberships #9047

Open
wants to merge 1 commit into
base: simplify-group-factory
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions h/models/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
import slugify
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship

from h import pubid
from h.db import Base, mixins
from h.models.user import User
from h.util.group import split_groupid

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

id = sa.Column("id", sa.Integer, autoincrement=True, primary_key=True)

user_id = sa.Column("user_id", sa.Integer, sa.ForeignKey("user.id"), nullable=False)
user = relationship("User", back_populates="memberships")
Copy link
Contributor Author

@seanh seanh Oct 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding GroupMembership.user and GroupMembership.group relationships as well. This allows you to do GroupMembership(user=user, group=group) instead of having to do GroupMembership(user_id=user.id, group_id=group.id).


group_id = sa.Column(
"group_id",
sa.Integer,
sa.ForeignKey("group.id", ondelete="cascade"),
nullable=False,
index=True,
)
group = relationship("Group", back_populates="memberships")

roles = sa.Column(
JSONB,
sa.CheckConstraint(
Expand Down Expand Up @@ -159,12 +166,31 @@ def groupid(self, value):
self.authority_provided_id = groupid_parts["authority_provided_id"]
self.authority = groupid_parts["authority"]

# Group membership
members = sa.orm.relationship(
"User",
secondary="user_group",
backref=sa.orm.backref("groups", order_by="Group.name"),
)
memberships = sa.orm.relationship("GroupMembership", back_populates="group")
Copy link
Contributor Author

@seanh seanh Oct 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replacing the Group.members relationship with Group.memberships, so any code or tests that was setting or mutating Group.members now has to be updated to use Group.memberships instead.


@property
def members(self) -> tuple[User, ...]:
"""
Return a tuple of this group's members.

This is a convenience property for when you want to access a group's
members (User objects) rather than its memberships (GroupMembership
objects).

This is not an SQLAlchemy relationship! SQLAlchemy emits a warning if
you try to have both Group.memberships and a Group.members
relationships at the same time because it can result in reads returning
conflicting data and in writes causing integrity errors or unexpected
inserts or deletes. See:

https://docs.sqlalchemy.org/en/20/orm/basic_relationships.html#combining-association-object-with-many-to-many-access-patterns

Since this is just a normal Python property setting or mutating it
(e.g. `group.members = [...]` or `group.members.append(...)`) wouldn't
be registered with SQLAlchemy and the changes wouldn't be saved to the
DB. So this is a read-only property that returns an immutable tuple.
"""
return tuple(membership.user for membership in self.memberships)
Comment on lines +171 to +193
Copy link
Contributor Author

@seanh seanh Oct 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a lot of code (and a lot of tests) that read Group.members and this avoids having to do [membership.user for membership in group.memberships] all the time.

An alternative approach would be SQLAlchemy's association proxy extension which would also allow the Group.member relationship to be writeable (in particular see the section Simplifying Association Objects, but at a glance this seems more trouble than it's worth.


scopes = sa.orm.relationship(
"GroupScope", backref="group", cascade="all, delete-orphan"
Expand Down
31 changes: 31 additions & 0 deletions h/models/user.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime
import re
from typing import TYPE_CHECKING

import sqlalchemy as sa
from sqlalchemy.ext.declarative import declared_attr
Expand All @@ -9,6 +10,10 @@
from h.exceptions import InvalidUserId
from h.util.user import format_userid, split_user

if TYPE_CHECKING:
from models.group import Group # pragma: nocover


USERNAME_MIN_LENGTH = 3
USERNAME_MAX_LENGTH = 30
USERNAME_PATTERN = "(?i)^[A-Z0-9._]+$"
Expand Down Expand Up @@ -290,6 +295,32 @@ def activate(self):

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

memberships = sa.orm.relationship("GroupMembership", back_populates="user")
Copy link
Contributor Author

@seanh seanh Oct 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not easy to see because the User.groups relationship wasn't defined here in the User class itself (it was defined only in the Group class and added to the User class via backref) but the old User.groups relationship has been removed and here I'm adding User.memberships to replace it. So any code or tests that was writing to or mutating User.groups now has to be updated to use User.memberships instead.

(Here I'm doing relationships in the same way as the modern examples in the SQLAlchemy docs where if you want the relationship to work bidirectionally you have to put it in both classes, which also seems better and more explicit.)


@property
def groups(self) -> tuple["Group", ...]:
"""
Return a tuple of this user's groups.
This is a convenience property for when you want to access a user's
groups (Group objects) rather than its memberships (GroupMembership
objects).
This is not an SQLAlchemy relationship! SQLAlchemy emits a warning if
you try to have both User.memberships and a User.groups relationships
at the same time because it can result in reads returning conflicting
data and in writes causing integrity errors or unexpected inserts or
deletes. See:
https://docs.sqlalchemy.org/en/20/orm/basic_relationships.html#combining-association-object-with-many-to-many-access-patterns
Since this is just a normal Python property setting or mutating it
(e.g. `user.groups = [...]` or `user.groups.append(...)`) wouldn't
be registered with SQLAlchemy and the changes wouldn't be saved to the
DB. So this is a read-only property that returns an immutable tuple.
"""
return tuple(membership.group for membership in self.memberships)

@sa.orm.validates("email")
def validate_email(self, _key, email):
if email is None:
Expand Down
4 changes: 1 addition & 3 deletions h/services/group_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,7 @@ def _create(self, name, userid, type_flags, scopes, **kwargs):
# self.publish() or `return group`.
self.db.flush()

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

return group
Expand Down
14 changes: 11 additions & 3 deletions h/services/group_members.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from functools import partial

from h import session
from h.models import GroupMembership


class GroupMembersService:
Expand Down Expand Up @@ -60,18 +61,25 @@ def member_join(self, group, userid):
if user in group.members:
return

group.members.append(user)
group.memberships.append(GroupMembership(user=user))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again avoiding a warning from SQLAlchemy for the same reason.


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

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

if user not in group.members:
matching_memberships = [
membership for membership in group.memberships if membership.user == user
]

if not matching_memberships:
return

group.members.remove(user)
for membership in matching_memberships:
self.db.delete(membership)
group.memberships.remove(membership)
user.memberships.remove(membership)
Comment on lines +79 to +82
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you just do group.memberships.remove(membership) that sets the GroupMembership's group_id to None but doesn't remove it from the DB session, and then you end up with a psycopg2.errors.NotNullViolation on the user_group.group_id column. So it's necessary to also call self.db.delete(membership).

The user.memberships.remove(membership) is also needed because removing the membership from the group's memberships doesn't seem to automatically remove it from the user's memberships and you end up with some functional tests crashing when some code later in the request cycle is reading user.memberships and trying to read user.memberships[i].group.name and crashing because one of the memberships in user.memberships has group=None.


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

Expand Down
1 change: 0 additions & 1 deletion tests/common/factories/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ class Meta:
joinable_by = JoinableBy.authority
readable_by = ReadableBy.members
writeable_by = WriteableBy.members
members = factory.LazyFunction(list)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has to be removed because Group.members is now a read-only property. I'm not replacing this with a memberships attribute in the factory: this would be pointless because models.Group.memberships defaults to [] anyway. The members attribute here was never necessary in the first place

authority_provided_id = Faker("hexify", text="^" * 30)
enforce_scope = True

Expand Down
4 changes: 3 additions & 1 deletion tests/functional/api/bulk/annotation_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import pytest

from h.models import GroupMembership


@pytest.mark.usefixtures("with_clean_db")
class TestBulkAnnotation:
Expand All @@ -23,7 +25,7 @@ def test_it_accepts_a_valid_request(self, make_request, factories):
group = factories.Group(
authority="lms.hypothes.is",
authority_provided_id="1234567890",
members=[user],
memberships=[GroupMembership(user=user)],
)
annotation_slim = factories.AnnotationSlim(
user=user,
Expand Down
15 changes: 11 additions & 4 deletions tests/functional/api/groups/members_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@ class TestReadMembers:
def test_it_returns_list_of_members_for_restricted_group_without_authn(
self, app, factories, db_session
):
group = factories.RestrictedGroup()
group.members = [factories.User(), factories.User(), factories.User()]
group = factories.RestrictedGroup(
memberships=[
GroupMembership(user=user)
for user in factories.User.create_batch(size=3)
]
)
db_session.commit()

res = app.get("/api/groups/{pubid}/members".format(pubid=group.pubid))

assert res.status_code == 200
Expand All @@ -22,7 +27,9 @@ def test_it_returns_list_of_members_if_user_has_access_to_private_group(
self, app, factories, db_session, group, user_with_token, token_auth_header
):
user, _ = user_with_token
group.members.extend([user, factories.User()])
group.memberships.extend(
[GroupMembership(user=user), GroupMembership(user=factories.User())]
)
db_session.commit()

res = app.get(
Expand Down Expand Up @@ -251,7 +258,7 @@ def third_party_group(db_session, factories):
@pytest.fixture
def group_member(group, db_session, factories):
user = factories.User()
group.members.append(user)
group.memberships.append(GroupMembership(user=user))
db_session.commit()
return user

Expand Down
16 changes: 7 additions & 9 deletions tests/functional/api/groups/read_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,8 @@ def test_it_returns_private_groups_along_with_world_groups(
self, app, factories, db_session, user_with_token, token_auth_header
):
user, _ = user_with_token
group1 = factories.Group()
db_session.add(GroupMembership(group_id=group1.id, user_id=user.id))
group2 = factories.Group()
db_session.add(GroupMembership(group_id=group2.id, user_id=user.id))
group1 = factories.Group(memberships=[GroupMembership(user=user)])
group2 = factories.Group(memberships=[GroupMembership(user=user)])
Comment on lines +24 to +25
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that we have the Group.memberships relationship I can do this and have the memberships be added to the DB session automatically instead of having to do separate db_session.add(GroupMembership(...)) calls.

db_session.commit()

res = app.get("/api/groups", headers=token_auth_header)
Expand All @@ -38,8 +36,9 @@ def test_it_overrides_authority_param_with_user_authority(
self, app, factories, db_session, user_with_token, token_auth_header
):
user, _ = user_with_token
group1 = factories.Group(authority=user.authority)
db_session.add(GroupMembership(group_id=group1.id, user_id=user.id))
group1 = factories.Group(
authority=user.authority, memberships=[GroupMembership(user=user)]
)
db_session.commit()

res = app.get("/api/groups?authority=whatever.com", headers=token_auth_header)
Expand Down Expand Up @@ -94,8 +93,7 @@ def test_it_returns_http_200_for_private_group_with_creator_authentication(
self, app, user_with_token, token_auth_header, factories, db_session
):
user, _ = user_with_token
group = factories.Group(creator=user)
db_session.add(GroupMembership(group_id=group.id, user_id=user.id))
group = factories.Group(creator=user, memberships=[GroupMembership(user=user)])
db_session.commit()

res = app.get(
Expand All @@ -109,7 +107,7 @@ def test_it_returns_http_200_for_private_group_with_member_authentication(
):
user, _ = user_with_token
group = factories.Group()
group.members.append(user)
group.memberships.append(GroupMembership(user=user))
db_session.commit()

res = app.get(
Expand Down
7 changes: 5 additions & 2 deletions tests/functional/api/profile_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import pytest

from h.models import GroupMembership


class TestGetProfile:
def test_it_returns_profile_with_single_group_when_not_authd(self, app):
Expand Down Expand Up @@ -147,8 +149,9 @@ def groups(factories):

@pytest.fixture
def user(groups, db_session, factories):
user = factories.User()
user.groups = groups
user = factories.User(
memberships=[GroupMembership(group=group) for group in groups]
)
db_session.commit()
return user

Expand Down
17 changes: 12 additions & 5 deletions tests/unit/h/jinja2_extensions/navbar_data_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from h_matchers import Any

from h.jinja_extensions.navbar_data import navbar_data
from h.models import GroupMembership


class TestNavbarData:
Expand Down Expand Up @@ -53,9 +54,9 @@ def test_it(self, pyramid_request, user):
def test_it_with_a_group_with_no_creator(
self, pyramid_request, user, factories, group_list_service
):
user.groups = group_list_service.associated_groups.return_value = [
factories.Group(creator=None)
]
group = factories.Group(creator=None)
group_list_service.associated_groups.return_value = [group]
user.memberships = [GroupMembership(group=group, user=user)]
pyramid_request.user = user

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

@pytest.fixture
def user(self, factories):
def user(self, db_session, factories):
user = factories.User()
user.groups = [factories.Group(creator=user), factories.Group()]

with db_session.no_autoflush:
user.memberships = [
GroupMembership(group=group)
for group in [factories.Group(creator=user), factories.Group()]
]

return user

@pytest.fixture(autouse=True)
Expand Down
32 changes: 32 additions & 0 deletions tests/unit/h/models/group_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,38 @@ def test_non_public_group():
assert not group.is_public


def test_members(factories):
users = factories.User.build_batch(size=2)
group = factories.Group.build(
memberships=[
models.GroupMembership(user=users[0]),
models.GroupMembership(user=users[1]),
]
)

assert group.members == tuple(users)


def test_members_is_readonly(factories):
group = factories.Group.build()
new_members = (factories.User.build(),)

with pytest.raises(
AttributeError, match="^property 'members' of 'Group' object has no setter$"
):
group.members = new_members


def test_members_is_immutable(factories):
group = factories.Group.build()
new_member = factories.User.build()

with pytest.raises(
AttributeError, match="^'tuple' object has no attribute 'append'$"
):
group.members.append(new_member)


class TestGroupMembership:
def test_defaults(self, db_session, user, group):
membership = models.GroupMembership(user_id=user.id, group_id=group.id)
Expand Down
Loading
Loading