Skip to content

Commit

Permalink
Sponsor model (implements #465) (#1106)
Browse files Browse the repository at this point in the history
Co-authored-by: Vidya Ramakrishnan <[email protected]>
  • Loading branch information
jace and vidya-ram authored Apr 22, 2021
1 parent 6b2236f commit 823446a
Show file tree
Hide file tree
Showing 19 changed files with 896 additions and 93 deletions.
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ repos:
'--remove-duplicate-keys',
]
- repo: https://github.com/pre-commit/mirrors-isort
rev: v5.7.0
rev: v5.8.0
hooks:
- id: isort
additional_dependencies:
Expand Down Expand Up @@ -56,12 +56,12 @@ repos:
- id: requirements-txt-fixer
- id: trailing-whitespace
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.790
rev: v0.812
hooks:
- id: mypy
additional_dependencies: [sqlalchemy-stubs==0.4]
- repo: https://gitlab.com/pycqa/flake8
rev: 3.8.4
rev: 3.9.1
hooks:
- id: flake8
additional_dependencies:
Expand Down
1 change: 1 addition & 0 deletions funnel/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
from .membership_mixin import * # isort:skip
from .organization_membership import * # isort:skip
from .project_membership import * # isort:skip
from .sponsor_membership import * # isort:skip
from .proposal_membership import * # isort:skip
from .site_membership import * # isort:skip
from .moderation import * # isort:skip
Expand Down
8 changes: 5 additions & 3 deletions funnel/models/commentset_membership.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
from typing import Set

from werkzeug.utils import cached_property

from coaster.sqlalchemy import immutable, with_roles

from . import User, db
from .commentvote import Commentset
from .helpers import reopen
from .membership_mixin import ImmutableMembershipMixin
from .membership_mixin import ImmutableUserMembershipMixin

__all__ = ['CommentsetMembership']


class CommentsetMembership(ImmutableMembershipMixin, db.Model):
class CommentsetMembership(ImmutableUserMembershipMixin, db.Model):
"""Membership roles for users who are commentset users and subscribers."""

__tablename__ = 'commentset_membership'
Expand Down Expand Up @@ -57,7 +59,7 @@ class CommentsetMembership(ImmutableMembershipMixin, db.Model):
)

@cached_property
def offered_roles(self):
def offered_roles(self) -> Set[str]:
"""
Roles offered by this membership record.
Expand Down
250 changes: 189 additions & 61 deletions funnel/models/membership_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from ..typing import OptionalMigratedTables
from . import BaseMixin, UuidMixin, db
from .profile import Profile
from .user import User

__all__ = [
Expand Down Expand Up @@ -53,6 +54,8 @@ class ImmutableMembershipMixin(UuidMixin, BaseMixin):
"""Support class for immutable memberships."""

__uuid_primary_key__ = True
#: Can granted_by be null? Only in memberships based on legacy data
__null_granted_by__ = False
#: List of columns that will be copied into a new row when a membership is amended
__data_columns__: Iterable[str] = ()
#: Parent column (declare as synonym of 'profile_id' or 'project_id' in subclasses)
Expand All @@ -74,7 +77,7 @@ class ImmutableMembershipMixin(UuidMixin, BaseMixin):
read={'subject', 'editor'},
)
#: Record type
record_type = immutable(
record_type: db.Column = immutable(
with_roles(
db.Column(
db.Integer,
Expand All @@ -86,23 +89,6 @@ class ImmutableMembershipMixin(UuidMixin, BaseMixin):
)
)

# mypy type declaration
user_id: db.Column

@declared_attr # type: ignore[no-redef]
def user_id(cls): # skipcq: PYL-E0102
return db.Column(
None,
db.ForeignKey('user.id', ondelete='CASCADE'),
nullable=False,
index=True,
)

@with_roles(read={'subject', 'editor'}, grants={'subject'})
@declared_attr
def user(cls):
return immutable(db.relationship(User, foreign_keys=[cls.user_id]))

@declared_attr
def revoked_by_id(cls):
"""Id of user who revoked the membership."""
Expand All @@ -125,7 +111,9 @@ def granted_by_id(cls):
for granted_by.
"""
return db.Column(
None, db.ForeignKey('user.id', ondelete='SET NULL'), nullable=True
None,
db.ForeignKey('user.id', ondelete='SET NULL'),
nullable=cls.__null_granted_by__,
)

@with_roles(read={'subject', 'editor'}, grants={'editor'})
Expand Down Expand Up @@ -154,42 +142,8 @@ def is_active(cls): # NOQA: N805
def is_invite(self) -> bool:
return self.record_type == MEMBERSHIP_RECORD_TYPE.INVITE

@with_roles(read={'subject', 'editor'})
@hybrid_property
def is_self_granted(self) -> bool:
"""Return True if the subject of this record is also the granting actor."""
return self.user_id == self.granted_by_id

@with_roles(read={'subject', 'editor'})
@hybrid_property
def is_self_revoked(self) -> bool:
"""Return True if the subject of this record is also the revoking actor."""
return self.user_id == self.revoked_by_id

@declared_attr
def __table_args__(cls):
if cls.parent_id is not None:
return (
db.Index(
'ix_' + cls.__tablename__ + '_active',
cls.parent_id.name,
'user_id',
unique=True,
postgresql_where=db.text('revoked_at IS NULL'),
),
)
else:
return (
db.Index(
'ix_' + cls.__tablename__ + '_active',
'user_id',
unique=True,
postgresql_where=db.text('revoked_at IS NULL'),
),
)

@cached_property
def offered_roles(self) -> Set:
def offered_roles(self) -> Set[str]:
"""Return roles offered by this membership record."""
return set()

Expand All @@ -205,9 +159,13 @@ def revoke(self, actor: User) -> None:
self.revoked_at = db.func.utcnow()
self.revoked_by = actor

def copy_template(self: MembershipType, **kwargs) -> MembershipType:
"""Make a copy of self for customization."""
raise NotImplementedError("Subclasses must implement copy_template")

@with_roles(call={'editor'})
def replace(
self, actor: User, accept=False, **roles: Dict[str, Any]
self: MembershipType, actor: User, accept=False, **roles: Dict[str, Any]
) -> MembershipType:
"""Replace this membership record with changes to role columns."""
if self.revoked_at is not None:
Expand Down Expand Up @@ -237,9 +195,7 @@ def replace(

self.revoked_at = db.func.utcnow()
self.revoked_by = actor
new = type(self)(
user=self.user, parent_id=self.parent_id, granted_by=self.granted_by
)
new = self.copy_template(parent_id=self.parent_id, granted_by=self.granted_by)

# if existing record type is INVITE, then ACCEPT or amend as new INVITE
# else replace it with AMEND
Expand All @@ -259,7 +215,9 @@ def replace(
db.session.add(new)
return new

def merge_and_replace(self, actor: User, other: MembershipType) -> MembershipType:
def merge_and_replace(
self: MembershipType, actor: User, other: MembershipType
) -> MembershipType:
"""Replace this record by merging roles from an independent record."""
if self.__class__ is not other.__class__:
raise TypeError("Merger requires membership records of the same type")
Expand Down Expand Up @@ -293,14 +251,72 @@ def merge_and_replace(self, actor: User, other: MembershipType) -> MembershipTyp
return replacement

@with_roles(call={'subject'})
def accept(self, actor: User) -> MembershipType:
def accept(self: MembershipType, actor: User) -> MembershipType:
"""Accept a membership invitation."""
if self.record_type != MEMBERSHIP_RECORD_TYPE.INVITE:
raise MembershipRecordTypeError("This membership record is not an invite")
if actor != self.user:
if 'subject' not in self.roles_for(actor):
raise ValueError("Invite must be accepted by the invited user")
return self.replace(actor, accept=True)


class ImmutableUserMembershipMixin(ImmutableMembershipMixin):
"""Support class for immutable memberships for users."""

# mypy type declaration
user_id: db.Column

@declared_attr # type: ignore[no-redef]
def user_id(cls): # skipcq: PYL-E0102
return db.Column(
None,
db.ForeignKey('user.id', ondelete='CASCADE'),
nullable=False,
index=True,
)

@with_roles(read={'subject', 'editor'}, grants={'subject'})
@declared_attr
def user(cls):
return immutable(db.relationship(User, foreign_keys=[cls.user_id]))

@declared_attr
def __table_args__(cls):
if cls.parent_id is not None:
return (
db.Index(
'ix_' + cls.__tablename__ + '_active',
cls.parent_id.name,
'user_id',
unique=True,
postgresql_where=db.text('revoked_at IS NULL'),
),
)
else:
return (
db.Index(
'ix_' + cls.__tablename__ + '_active',
'user_id',
unique=True,
postgresql_where=db.text('revoked_at IS NULL'),
),
)

@with_roles(read={'subject', 'editor'})
@hybrid_property
def is_self_granted(self) -> bool:
"""Return True if the subject of this record is also the granting actor."""
return self.user_id == self.granted_by_id

@with_roles(read={'subject', 'editor'})
@hybrid_property
def is_self_revoked(self) -> bool:
"""Return True if the subject of this record is also the revoking actor."""
return self.user_id == self.revoked_by_id

def copy_template(self: MembershipType, **kwargs) -> MembershipType:
return type(self)(user=self.user, **kwargs)

@classmethod
def migrate_user(cls, old_user: User, new_user: User) -> OptionalMigratedTables:
"""
Expand Down Expand Up @@ -343,5 +359,117 @@ def migrate_user(cls, old_user: User, new_user: User) -> OptionalMigratedTables:
cls.query.filter(cls.user == old_user).update(
{'user_id': new_user.id}, synchronize_session=False
)
# Also update the revoked_by and granted_by user accounts
cls.query.filter(cls.revoked_by == old_user).update(
{'revoked_by_id': new_user.id}, synchronize_session=False
)
cls.query.filter(cls.granted_by == old_user).update(
{'granted_by_id': new_user.id}, synchronize_session=False
)
db.session.flush()
return None


class ImmutableProfileMembershipMixin(ImmutableMembershipMixin):
"""Support class for immutable memberships for profiles."""

# mypy type declaration
profile_id: db.Column

@declared_attr # type: ignore[no-redef]
def profile_id(cls): # skipcq: PYL-E0102
return db.Column(
None,
db.ForeignKey('profile.id', ondelete='CASCADE'),
nullable=False,
index=True,
)

@declared_attr
def __table_args__(cls):
if cls.parent_id is not None:
return (
db.Index(
'ix_' + cls.__tablename__ + '_active',
cls.parent_id.name,
'profile_id',
unique=True,
postgresql_where=db.text('revoked_at IS NULL'),
),
)
else:
return (
db.Index(
'ix_' + cls.__tablename__ + '_active',
'profile_id',
unique=True,
postgresql_where=db.text('revoked_at IS NULL'),
),
)

@with_roles(read={'subject', 'editor'})
@hybrid_property
def is_self_granted(self) -> bool:
"""Return True if the subject of this record is also the granting actor."""
return 'subject' in self.roles_for(self.granted_by)

@with_roles(read={'subject', 'editor'})
@hybrid_property
def is_self_revoked(self) -> bool:
"""Return True if the subject of this record is also the revoking actor."""
return 'subject' in self.roles_for(self.revoked_by)

def copy_template(self: MembershipType, **kwargs) -> MembershipType:
return type(self)(profile=self.profile, **kwargs)

@with_roles(read={'subject', 'editor'}, grants_via={None: {'admin': 'subject'}})
@declared_attr
def profile(cls):
return immutable(db.relationship(Profile, foreign_keys=[cls.profile_id]))

@classmethod
def migrate_profile(
cls, old_profile: Profile, new_profile: Profile
) -> OptionalMigratedTables:
"""
Migrate memberhip records from one profile to another.
If both profiles have active records, they are merged into a new record in the
new profile's favour. All revoked records for the old profile are transferred to
the new profile.
"""
# Look up all active membership records of the subclass's type for the old
# profile. `cls` here represents the subclass.
old_profile_records = cls.query.filter(
cls.profile == old_profile, cls.revoked_at.is_(None)
).all()
# Look up all conflicting memberships for the new profile. Limit lookups by
# parent except when the membership type doesn't have a parent.
if cls.parent_id is not None:
new_profile_records = cls.query.filter(
cls.profile == new_profile,
cls.revoked_at.is_(None),
cls.parent_id.in_([r.parent_id for r in old_profile_records]),
).all()
else:
new_profile_records = cls.query.filter(
cls.profile == new_profile,
cls.revoked_at.is_(None),
).all()
new_profile_records_by_parent = {r.parent_id: r for r in new_profile_records}

for record in old_profile_records:
if record.parent_id in new_profile_records_by_parent:
# Where there is a conflict, merge the records
new_profile_records_by_parent[record.parent_id].merge_and_replace(
new_profile, record
)
db.session.flush()

# Transfer all revoked records and non-conflicting active records. At this point
# no filter is necessary as the conflicting records have all been merged.
cls.query.filter(cls.profile == old_profile).update(
{'profile_id': new_profile.id}, synchronize_session=False
)
db.session.flush()
return None
Loading

0 comments on commit 823446a

Please sign in to comment.