diff --git a/funnel/models/__init__.py b/funnel/models/__init__.py index 84f2b24e4..5dbca2ce2 100644 --- a/funnel/models/__init__.py +++ b/funnel/models/__init__.py @@ -61,6 +61,8 @@ from .project import * # isort:skip from .update import * # isort:skip from .proposal import * # isort:skip +from .follower import * # isort:skip +from .registration import * # isort:skip from .rsvp import * # isort:skip from .saved import * # isort:skip from .session import * # isort:skip diff --git a/funnel/models/follower.py b/funnel/models/follower.py new file mode 100644 index 000000000..b2ac2ed33 --- /dev/null +++ b/funnel/models/follower.py @@ -0,0 +1,71 @@ +"""Project registration.""" + +from __future__ import annotations + +from coaster.sqlalchemy import immutable, with_roles + +from . import Mapped, db, sa +from .membership_mixin import ActorMembershipMixin +from .profile import Profile + +__all__ = ['Follower'] + + +class Follower(ActorMembershipMixin, db.Model): # type: ignore[name-defined] + """A user can register on a project.""" + + __tablename__ = 'follower' + + __roles__ = { + 'all': { + 'read': {'urls', 'user'}, + 'call': {'url_for'}, + }, + } + __datasets__ = { + 'primary': { + 'urls', + 'uuid_b58', + 'offered_roles', + 'user', + 'project', + }, + 'without_parent': { + 'urls', + 'uuid_b58', + 'offered_roles', + 'user', + }, + 'related': { + 'urls', + 'uuid_b58', + 'offered_roles', + }, + } + + profile_id: Mapped[int] = immutable( + with_roles( + sa.orm.mapped_column( + None, sa.ForeignKey('profile.id', ondelete='CASCADE'), nullable=False + ), + read={'subject', 'editor'}, + ), + ) + profile: Mapped[Profile] = immutable( + with_roles( + sa.orm.relationship( + Profile, + backref=sa.orm.backref( + 'all_memberships', + lazy='dynamic', + cascade='all', + passive_deletes=True, + ), + ), + read={'subject', 'editor'}, + grants_via={None: {'editor'}}, + ), + ) + parent: Mapped[Profile] = sa.orm.synonym('profile') # type: ignore[assignment] + parent_id: Mapped[int] = sa.orm.synonym('profile_id') # type: ignore[assignment] + parent_id_column = 'profile_id' diff --git a/funnel/models/membership_mixin.py b/funnel/models/membership_mixin.py index 707958fb3..6b4cfaa76 100644 --- a/funnel/models/membership_mixin.py +++ b/funnel/models/membership_mixin.py @@ -14,6 +14,7 @@ TypeVar, Union, ) +from uuid import UUID from sqlalchemy import event from sqlalchemy.sql.expression import ColumnElement @@ -29,12 +30,15 @@ BaseMixin, Mapped, UuidMixin, + UUIDType, db, declarative_mixin, declared_attr, hybrid_property, sa, ) +from .email_address import EmailAddress +from .phone_number import PhoneNumber from .profile import Profile from .reorder_mixin import ReorderMixin from .user import EnumerateMembershipsMixin, User @@ -51,7 +55,7 @@ MembershipType = TypeVar('MembershipType', bound='ImmutableMembershipMixin') FrozenAttributionType = TypeVar('FrozenAttributionType', bound='FrozenAttributionMixin') -SubjectType = Union[Mapped[User], Mapped[Profile]] +SubjectType = Union[User, Profile] # --- Enum ----------------------------------------------------------------------------- @@ -393,7 +397,7 @@ def user(cls) -> Mapped[User]: @declared_attr @classmethod - def subject(cls) -> Mapped[User]: + def subject(cls) -> Mapped[User]: # type: ignore[override] """Subject of this membership record.""" return sa.orm.synonym('user') @@ -515,7 +519,7 @@ def profile(cls) -> Mapped[Profile]: @declared_attr @classmethod - def subject(cls) -> Mapped[Profile]: + def subject(cls) -> Mapped[Profile]: # type: ignore[override] """Subject of this membership record.""" return sa.orm.synonym('profile') @@ -724,6 +728,249 @@ def freeze_subject_attribution( return membership +@declarative_mixin +class ActorMembershipMixin(FrozenAttributionMixin, ImmutableMembershipMixin): + """Provide a base for membership models that accept a user or a contact address.""" + + __tablename__: str # Defined by subclasses + __data_columns__ = ('user', 'email_address', 'phone_number', 'browserid') + + @declared_attr + @classmethod + def user_id(cls) -> Mapped[int]: + """Foreign key column to user table.""" + return sa.orm.mapped_column( + sa.Integer, + sa.ForeignKey('user.id', ondelete='CASCADE'), + nullable=True, + index=True, + ) + + @with_roles(read={'subject', 'editor'}, grants={'subject'}) + @declared_attr + @classmethod + def user(cls) -> Mapped[User]: + """User who is the subject of this membership record.""" + return sa.orm.relationship(User, foreign_keys=[cls.user_id]) + + @declared_attr + @classmethod + def subject(cls) -> Mapped[User]: # type: ignore[override] + """Subject of this membership record.""" + return sa.orm.synonym('user') + + @declared_attr + @classmethod + def email_address_id(cls) -> Mapped[int]: + return sa.orm.mapped_column( + sa.Integer, sa.ForeignKey('email_address.id'), nullable=True + ) + + @declared_attr + @classmethod + def email_address(cls) -> Mapped[EmailAddress]: + """Instance of :class:`EmailAddress` as a relationship.""" + backref_name = 'used_in_' + cls.__tablename__ + EmailAddress.__backrefs__.add(backref_name) + return sa.orm.relationship( + EmailAddress, + backref=sa.orm.backref( + backref_name, + primaryjoin=sa.and_( + cls.email_address_id == EmailAddress.id, cls.revoked_at.isnot(None) + ), + ), + ) + + @declared_attr + @classmethod + def phone_number_id(cls) -> Mapped[int]: + return sa.orm.mapped_column( + sa.Integer, sa.ForeignKey('phone_number.id'), nullable=True + ) + + @declared_attr + @classmethod + def phone_number(cls) -> Mapped[PhoneNumber]: + """Instance of :class:`PhoneNumber` as a relationship.""" + backref_name = 'used_in_' + cls.__tablename__ + PhoneNumber.__backrefs__.add(backref_name) + return sa.orm.relationship( + PhoneNumber, + backref=sa.orm.backref( + backref_name, + primaryjoin=sa.and_( + cls.phone_number_id == PhoneNumber.id, cls.revoked_at.isnot(None) + ), + ), + ) + + @declared_attr + @classmethod + def browserid(cls) -> Mapped[UUID]: # TODO: Plug into larger framework + """Random identifier for anonymous user's browser (saved to cookie).""" + return sa.orm.mapped_column(UUIDType(binary=False), nullable=True) + + @declared_attr.directive + @classmethod + def __table_args__(cls) -> tuple: + """Table arguments for SQLAlchemy.""" + return ( + ( + sa.Index( + 'ix_' + cls.__tablename__ + '_user_active', + cls.parent_id_column, + 'user_id', + unique=True, + postgresql_where='revoked_at IS NULL AND user_id IS NOT NULL', + ), + sa.Index( + 'ix_' + cls.__tablename__ + '_email_active', + cls.parent_id_column, + 'email_address_id', + unique=True, + postgresql_where=( + 'revoked_at IS NULL AND email_address_id IS NOT NULL' + ), + ), + sa.Index( + 'ix_' + cls.__tablename__ + '_phone_active', + cls.parent_id_column, + 'phone_number_id', + unique=True, + postgresql_where=( + 'revoked_at IS NULL AND phone_number_id IS NOT NULL' + ), + ), + ) + if cls.parent_id_column is not None + else ( + sa.Index( + 'ix_' + cls.__tablename__ + '_user_active', + 'user_id', + unique=True, + postgresql_where='revoked_at IS NULL AND user_id IS NOT NULL', + ), + sa.Index( + 'ix_' + cls.__tablename__ + '_email_active', + 'email_address_id', + unique=True, + postgresql_where=( + 'revoked_at IS NULL AND email_address_id IS NOT NULL' + ), + ), + sa.Index( + 'ix_' + cls.__tablename__ + '_phone_active', + 'phone_number_id', + unique=True, + postgresql_where=( + 'revoked_at IS NULL AND phone_number_id IS NOT NULL' + ), + ), + ) + ) + ( + sa.CheckConstraint( + sa.or_( + # User must be present, or + sa.column('user_id').isnot(None), + # If user is not present, one of phone number or email address must + # be present + sa.and_( + sa.column('user_id').is_(None), + sa.or_( + sa.column('email_address_id').isnot(None), + sa.column('phone_number_id').isnot(None), + ), + ), + ) + ), + ) + + @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(is_self_granted, 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 + + with_roles(is_self_revoked, read={'subject', 'editor'}) + + def copy_template(self: MembershipType, **kwargs) -> MembershipType: + return type(self)( + user=self.user, + email_address=self.email_address, + phone_number=self.phone_number, + browserid=self.browserid, + **kwargs, + ) + + @property + def email_address_reference_is_active(self) -> bool: + return self.is_active + + @property + def phone_number_reference_is_active(self) -> bool: + return self.is_active + + @classmethod + def migrate_user( # type: ignore[return] + cls, old_user: User, new_user: User + ) -> OptionalMigratedTables: + """ + Migrate memberhip records from one user to another. + + If both users have active records, they are merged into a new record in the new + user's favour. All revoked records for the old user are transferred to the new + user. + """ + # Look up all active membership records of the subclass's type for the old user + # account. `cls` here represents the subclass. + old_user_records = cls.query.filter( + cls.user == old_user, cls.revoked_at.is_(None) + ).all() + # Look up all conflicting memberships for the new user account. Limit lookups by + # parent except when the membership type doesn't have a parent (SiteMembership). + if cls.parent_id is not None: + new_user_records = cls.query.filter( + cls.user == new_user, + cls.revoked_at.is_(None), + cls.parent_id.in_([r.parent_id for r in old_user_records]), + ).all() + else: + new_user_records = cls.query.filter( + cls.user == new_user, + cls.revoked_at.is_(None), + ).all() + new_user_records_by_parent = {r.parent_id: r for r in new_user_records} + + for record in old_user_records: + if record.parent_id in new_user_records_by_parent: + # Where there is a conflict, merge the records + new_user_records_by_parent[record.parent_id].merge_and_replace( + new_user, 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.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() + + class AmendMembership(Generic[MembershipType]): """ Helper class for editing a membership record from a form. diff --git a/funnel/models/registration.py b/funnel/models/registration.py new file mode 100644 index 000000000..b162501c2 --- /dev/null +++ b/funnel/models/registration.py @@ -0,0 +1,87 @@ +"""Project registration.""" + +from __future__ import annotations + +from typing import Any, Dict + +from coaster.sqlalchemy import immutable, with_roles + +from . import Mapped, db, json_type, sa +from .membership_mixin import ActorMembershipMixin +from .project import Project + +__all__ = ['Registration'] + + +class Registration(ActorMembershipMixin, db.Model): # type: ignore[name-defined] + """A user can register on a project.""" + + __tablename__ = 'registration' + + __data_columns__ = ActorMembershipMixin.__data_columns__ + ('form',) + + __roles__ = { + 'all': { + 'read': {'urls', 'user'}, + 'call': {'url_for'}, + }, + } + __datasets__ = { + 'primary': { + 'urls', + 'uuid_b58', + 'offered_roles', + 'number', + 'user', + 'project', + }, + 'without_parent': { + 'urls', + 'uuid_b58', + 'offered_roles', + 'number', + 'user', + }, + 'related': { + 'urls', + 'uuid_b58', + 'offered_roles', + 'number', + }, + } + + project_id: Mapped[int] = immutable( + with_roles( + sa.orm.mapped_column( + None, sa.ForeignKey('project.id', ondelete='CASCADE'), nullable=False + ), + read={'subject', 'editor'}, + ), + ) + project: Mapped[Project] = immutable( + with_roles( + sa.orm.relationship( + Project, + backref=sa.orm.backref( + 'all_memberships', + lazy='dynamic', + cascade='all', + passive_deletes=True, + ), + ), + read={'subject', 'editor'}, + grants_via={None: {'editor'}}, + ), + ) + parent: Mapped[Project] = sa.orm.synonym('project') # type: ignore[assignment] + parent_id: Mapped[int] = sa.orm.synonym('project_id') # type: ignore[assignment] + parent_id_column = 'project_id' + + # Form response data, if registration is gated with a form + form: Mapped[Dict[str, Any]] = immutable( + with_roles( + sa.orm.mapped_column(json_type, nullable=True), + read={'owner', 'editor', 'subject'}, + write={'subject'}, + ) + )