Ejemplo n.º 1
0
class MultiroleDocument(BaseMixin, db.Model):
    """Test model that grants multiple roles via RoleMembership"""

    __tablename__ = 'multirole_document'

    parent_id = db.Column(None, db.ForeignKey('multirole_parent.id'))
    parent = with_roles(
        db.relationship(MultiroleParent),
        # grants_via[None] implies that these roles are granted by parent.roles_for(),
        # and not via parent.`actor_attr`. While other roles may also be granted by
        # parent.roles_for(), we only want one, and we want to give it a different name
        # here. The dict maps source role to destination role.
        grants_via={None: {
            'prole1': 'parent_prole1'
        }},
    )

    # Acquire parent_role through parent.user (a scalar relationship)
    # Acquire parent_other_role too (will be cached alongside parent_role)
    # Acquire role1 through both relationships (query and list relationships)
    # Acquire role2 and role3 via only one relationship each
    # This contrived setup is only to test that it works via all relationship types
    __roles__ = {
        'parent_role': {
            'granted_via': {
                'parent': 'user'
            }
        },
        'parent_other_role': {
            'granted_via': {
                'parent': 'user'
            }
        },
        'role1': {
            'granted_via': {
                'rel_lazy': 'user',
                'rel_list': 'user'
            }
        },
        'role2': {
            'granted_via': {
                'rel_lazy': 'user'
            }
        },
        'incorrectly_specified_role': {
            'granted_via': {
                'rel_list': None
            }
        },
    }

    # Grant via a query relationship
    rel_lazy = db.relationship(RoleMembership, lazy='dynamic')
    # Grant via a list-like relationship
    rel_list = with_roles(db.relationship(RoleMembership),
                          grants_via={'user': {'role3'}})
Ejemplo n.º 2
0
class AutoRoleModel(RoleMixin, db.Model):
    __tablename__ = 'auto_role_model'

    # This model doesn't specify __roles__. It only uses with_roles.
    # It should still work
    id = db.Column(db.Integer, primary_key=True)  # NOQA: A003
    with_roles(id, read={'all'})

    name = db.Column(db.Unicode(250))
    with_roles(name, rw={'owner'}, read={'all'})
Ejemplo n.º 3
0
class RoleGrantSynonym(BaseMixin, db.Model):
    """Test model for granting roles to synonyms"""

    __tablename__ = 'role_grant_synonym'

    # Base column has roles defined
    datacol = with_roles(db.Column(db.UnicodeText()), rw={'owner'})
    # Synonym has no roles defined, so it acquires from the target
    altcol_unroled = db.synonym('datacol')
    # However, when the synonym has roles defined, these override the target's
    altcol_roled = with_roles(db.synonym('datacol'), read={'all'})
Ejemplo n.º 4
0
class Commentset:
    project = with_roles(
        db.relationship(Project, uselist=False, back_populates='commentset'),
        grants_via={None: {
            'editor': 'document_subscriber'
        }},
    )
Ejemplo n.º 5
0
class RoleGrantOne(BaseMixin, db.Model):
    """Test model for granting roles to users in a one-to-many relationship"""

    __tablename__ = 'role_grant_one'

    user_id = db.Column(None, db.ForeignKey('role_user.id'))
    user = with_roles(db.relationship(RoleUser), grants={'creator'})
Ejemplo n.º 6
0
class RoleModel(DeclaredAttrMixin, RoleMixin, db.Model):
    __tablename__ = 'role_model'

    # Approach one, declare roles in advance.
    # 'all' is a special role that is always granted from the base class

    __roles__ = {'all': {'read': {'id', 'name', 'title'}}}

    __datasets__ = {
        'minimal': {'id', 'name'},
        'extra': {'id', 'name', 'mixed_in1'}
    }
    # Additional dataset members are defined using with_roles

    # Approach two, annotate roles on the attributes.
    # These annotations always add to anything specified in __roles__

    id = db.Column(db.Integer, primary_key=True)  # NOQA: A003
    name = with_roles(db.Column(db.Unicode(250)),
                      rw={'owner'})  # Specify read+write access

    title = with_roles(
        db.Column(db.Unicode(250)),
        write={'owner', 'editor'},
        datasets={'minimal', 'extra', 'third'},  # 'third' is unique here
    )  # Grant 'owner' and 'editor' write but not read access

    defval = with_roles(db.deferred(db.Column(db.Unicode(250))), rw={'owner'})

    @with_roles(call={'all'}
                )  # 'call' grants call access to the decorated method
    def hello(self):
        return "Hello!"

    # RoleMixin provides a `roles_for` that automatically grants roles from
    # `granted_by` declarations. See the RoleGrant models below for examples.
    # This is optional however, and your model can take independent responsibility
    # for granting roles given an actor and anchors (an iterable). The format for
    # anchors is not specified by RoleMixin.

    def roles_for(self, actor=None, anchors=()):
        # Calling super gives us a set with the standard roles
        roles = super(RoleModel, self).roles_for(actor, anchors)
        if 'owner-secret' in anchors:
            roles.add('owner')  # Grant owner role
        return roles
Ejemplo n.º 7
0
class Proposal:
    #: For reading and setting labels from the edit form
    formlabels = ProposalLabelProxy()

    labels = with_roles(
        db.relationship(Label,
                        secondary=proposal_label,
                        back_populates='proposals'),
        read={'all'},
    )
Ejemplo n.º 8
0
class Project:
    active_crew_memberships = with_roles(
        db.relationship(
            ProjectCrewMembership,
            lazy='dynamic',
            primaryjoin=db.and_(
                ProjectCrewMembership.project_id == Project.id,
                ProjectCrewMembership.is_active,
            ),
            viewonly=True,
        ),
        grants_via={
            'user': {'editor', 'concierge', 'usher', 'participant', 'crew'}
        },
    )

    active_editor_memberships = db.relationship(
        ProjectCrewMembership,
        lazy='dynamic',
        primaryjoin=db.and_(
            ProjectCrewMembership.project_id == Project.id,
            ProjectCrewMembership.is_active,
            ProjectCrewMembership.is_editor.is_(True),
        ),
        viewonly=True,
    )

    active_concierge_memberships = db.relationship(
        ProjectCrewMembership,
        lazy='dynamic',
        primaryjoin=db.and_(
            ProjectCrewMembership.project_id == Project.id,
            ProjectCrewMembership.is_active,
            ProjectCrewMembership.is_concierge.is_(True),
        ),
        viewonly=True,
    )

    active_usher_memberships = db.relationship(
        ProjectCrewMembership,
        lazy='dynamic',
        primaryjoin=db.and_(
            ProjectCrewMembership.project_id == Project.id,
            ProjectCrewMembership.is_active,
            ProjectCrewMembership.is_usher.is_(True),
        ),
        viewonly=True,
    )

    crew = DynamicAssociationProxy('active_crew_memberships', 'user')
    editors = DynamicAssociationProxy('active_editor_memberships', 'user')
    concierges = DynamicAssociationProxy('active_concierge_memberships',
                                         'user')
    ushers = DynamicAssociationProxy('active_usher_memberships', 'user')
Ejemplo n.º 9
0
class Commentset:
    proposal = with_roles(
        db.relationship(Proposal, uselist=False, back_populates='commentset'),
        # TODO: Remove creator to subscriber mapping when proposals use memberships
        grants_via={
            None: {
                'presenter': 'document_subscriber',
                'creator': 'document_subscriber'
            }
        },
    )
Ejemplo n.º 10
0
class RoleModel(DeclaredAttrMixin, RoleMixin, db.Model):
    __tablename__ = 'role_model'

    # Approach one, declare roles in advance.
    # 'all' is a special role that is always granted from the base class

    __roles__ = {
        'all': {
            'read': {'id', 'name', 'title'}
        }
    }

    # Approach two, annotate roles on the attributes.
    # These annotations always add to anything specified in __roles__

    id = db.Column(db.Integer, primary_key=True)
    name = with_roles(db.Column(db.Unicode(250)),
        rw={'owner'})  # Specify read+write access

    title = with_roles(db.Column(db.Unicode(250)),
        write={'owner', 'editor'})  # Grant 'owner' and 'editor' write but not read access

    defval = with_roles(db.deferred(db.Column(db.Unicode(250))),
        rw={'owner'})

    @with_roles(call={'all'})  # 'call' grants call access to the decorated method
    def hello(self):
        return "Hello!"

    # Your model is responsible for granting roles given an actor or anchors
    # (an iterable). The format for anchors is not specified by RoleMixin.

    def roles_for(self, actor=None, anchors=()):
        # Calling super give us a result set with the standard roles
        result = super(RoleModel, self).roles_for(actor, anchors)
        if 'owner-secret' in anchors:
            result.add('owner')  # Grant owner role
        return result
Ejemplo n.º 11
0
class MultiroleChild(BaseMixin, db.Model):
    """Model that inherits roles from its parent"""

    __tablename__ = 'multirole_child'
    parent_id = db.Column(None, db.ForeignKey('multirole_document.id'))
    parent = with_roles(
        db.relationship(MultiroleDocument),
        grants_via={
            'parent.user': {'super_parent_role'},  # Maps to parent.parent.user
            'rel_lazy.user': {  # Maps to parent.rel_lazy[item].user
                # Map role2 and role3, but explicitly ignore role1
                'role2': 'parent_role2',
                'role3': 'parent_role3',
            },
        },
    )
Ejemplo n.º 12
0
class SharedProfileMixin:
    """
    Common methods between User and Organization to link to Profile
    """

    # The `name` property in User and Organization is not over here because
    # of what seems to be a SQLAlchemy bug: we can't override the expression
    # (both models need separate expressions) without triggering an inspection
    # of the `profile` relationship, which does not exist yet as the backrefs
    # are only fully setup when module loading is finished.
    # Doc: https://docs.sqlalchemy.org/en/latest/orm/extensions/hybrid.html#reusing-hybrid-properties-across-subclasses

    def is_valid_name(self, value):
        if not valid_username(value):
            return False
        existing = Profile.get(value)
        if existing and existing.owner != self:
            return False
        return True

    def validate_name_candidate(self, name):
        if name and self.name and name.lower() == self.name.lower():
            # Same name, or only a case change. No validation required
            return
        return Profile.validate_name_candidate(name)

    @property
    def has_public_profile(self):
        """Controls the visibility state of a public profile"""
        return self.profile is not None and self.profile.state.PUBLIC

    with_roles(has_public_profile, read={'all'}, write={'owner'})

    @property
    def avatar(self):
        return (self.profile.logo_url if self.profile and self.profile.logo_url
                and self.profile.logo_url.url else '')

    @with_roles(read={'all'})
    @property
    def profile_url(self):
        return self.profile.url_for() if self.has_public_profile else None
class Organization:
    active_admin_memberships = with_roles(
        db.relationship(
            OrganizationMembership,
            lazy='dynamic',
            primaryjoin=db.and_(
                db.remote(
                    OrganizationMembership.organization_id) == Organization.id,
                OrganizationMembership.is_active,
            ),
            order_by=OrganizationMembership.granted_at.asc(),
            viewonly=True,
        ),
        grants_via={'user': {'admin', 'owner'}},
    )

    active_owner_memberships = db.relationship(
        OrganizationMembership,
        lazy='dynamic',
        primaryjoin=db.and_(
            db.remote(
                OrganizationMembership.organization_id) == Organization.id,
            OrganizationMembership.is_active,
            OrganizationMembership.is_owner.is_(True),
        ),
        viewonly=True,
    )

    active_invitations = db.relationship(
        OrganizationMembership,
        lazy='dynamic',
        primaryjoin=db.and_(
            db.remote(
                OrganizationMembership.organization_id) == Organization.id,
            OrganizationMembership.is_invite,
            ~OrganizationMembership.is_active,
        ),
        viewonly=True,
    )

    owner_users = DynamicAssociationProxy('active_owner_memberships', 'user')
    admin_users = DynamicAssociationProxy('active_admin_memberships', 'user')
Ejemplo n.º 14
0
class User:
    all_notifications = with_roles(
        db.relationship(
            UserNotification,
            lazy='dynamic',
            order_by=UserNotification.created_at.desc(),
        ),
        read={'owner'},
    )

    notification_preferences = db.relationship(
        NotificationPreferences,
        collection_class=column_mapped_collection(
            NotificationPreferences.notification_type
        ),
    )

    # This relationship is wrapped in a property that creates it on first access
    _main_notification_preferences = db.relationship(
        NotificationPreferences,
        primaryjoin=db.and_(
            NotificationPreferences.user_id == User.id,
            NotificationPreferences.notification_type == '',
        ),
        uselist=False,
    )

    @property
    def main_notification_preferences(self):
        if not self._main_notification_preferences:
            self._main_notification_preferences = NotificationPreferences(
                user=self,
                notification_type='',
                by_email=True,
                by_sms=False,
                by_webpush=False,
                by_telegram=False,
                by_whatsapp=False,
            )
            db.session.add(self._main_notification_preferences)
        return self._main_notification_preferences
Ejemplo n.º 15
0
class Proposal:
    active_memberships = with_roles(
        db.relationship(
            ProposalMembership,
            lazy='dynamic',
            primaryjoin=db.and_(
                ProposalMembership.proposal_id == Proposal.id,
                ProposalMembership.is_active,
            ),
            viewonly=True,
        ),
        grants_via={'user': {'reviewer', 'presenter'}},
    )

    active_reviewer_memberships = db.relationship(
        ProposalMembership,
        lazy='dynamic',
        primaryjoin=db.and_(
            ProposalMembership.proposal_id == Proposal.id,
            ProposalMembership.is_active,
            ProposalMembership.is_reviewer.is_(True),
        ),
        viewonly=True,
    )

    active_presenter_memberships = db.relationship(
        ProposalMembership,
        lazy='dynamic',
        primaryjoin=db.and_(
            ProposalMembership.proposal_id == Proposal.id,
            ProposalMembership.is_active,
            ProposalMembership.is_presenter.is_(True),
        ),
        viewonly=True,
    )

    members = DynamicAssociationProxy('active_memberships', 'user')
    reviewers = DynamicAssociationProxy('active_reviewer_memberships', 'user')
    presenters = DynamicAssociationProxy('active_presenters_memberships',
                                         'user')
Ejemplo n.º 16
0
class DeclaredAttrMixin(object):
    # with_roles can be used within a declared attr
    @declared_attr
    def mixed_in1(cls):
        return with_roles(db.Column(db.Unicode(250)), rw={'owner'})

    # declared_attr_roles is deprecated since 0.6.1. Use with_roles
    # as the outer decorator now. It remains here for the test case.
    @declared_attr
    @declared_attr_roles(rw={'owner', 'editor'}, read={'all'})
    def mixed_in2(cls):
        return db.Column(db.Unicode(250))

    # with_roles can also be used outside a declared attr
    @with_roles(rw={'owner'})
    @declared_attr
    def mixed_in3(cls):
        return db.Column(db.Unicode(250))

    # A regular column from the mixin
    mixed_in4 = db.Column(db.Unicode(250))
    mixed_in4 = with_roles(mixed_in4, rw={'owner'})
Ejemplo n.º 17
0
class ImmutableMembershipMixin(UuidMixin, BaseMixin):
    """
    Support class for immutable memberships
    """

    __uuid_primary_key__ = True
    #: List of columns that will be copied into a new row when a membership is amended
    __data_columns__ = ()
    #: Parent column (override as synonym of 'profile_id' or 'project_id' in the subclasses)
    parent_id = None

    #: Start time of membership, ordinarily a mirror of created_at except
    #: for records created when the member table was added to the database
    granted_at = immutable(
        with_roles(
            db.Column(db.TIMESTAMP(timezone=True),
                      nullable=False,
                      default=db.func.utcnow()),
            read={'subject', 'editor'},
        ))
    #: End time of membership, ordinarily a mirror of updated_at
    revoked_at = with_roles(
        db.Column(db.TIMESTAMP(timezone=True), nullable=True),
        read={'subject', 'editor'},
    )
    #: Record type
    record_type = immutable(
        with_roles(
            db.Column(
                db.Integer,
                StateManager.check_constraint('record_type',
                                              MEMBERSHIP_RECORD_TYPE),
                default=MEMBERSHIP_RECORD_TYPE.DIRECT_ADD,
                nullable=False,
            ),
            read={'subject', 'editor'},
        ))

    @declared_attr
    def user_id(cls):
        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 db.relationship(User, foreign_keys=[cls.user_id])

    @declared_attr
    def revoked_by_id(cls):
        """Id of user who revoked the membership"""
        return db.Column(None,
                         db.ForeignKey('user.id', ondelete='SET NULL'),
                         nullable=True)

    @with_roles(read={'subject'}, grants={'editor'})
    @declared_attr
    def revoked_by(cls):
        """User who revoked the membership"""
        return db.relationship(User, foreign_keys=[cls.revoked_by_id])

    @declared_attr
    def granted_by_id(cls):
        """
        Id of user who assigned the membership.

        This is nullable only for historical data. New records always require a value for granted_by
        """
        return db.Column(None,
                         db.ForeignKey('user.id', ondelete='SET NULL'),
                         nullable=True)

    @with_roles(read={'subject', 'editor'}, grants={'editor'})
    @declared_attr
    def granted_by(cls):
        """User who assigned the membership"""
        return db.relationship(User, foreign_keys=[cls.granted_by_id])

    @hybrid_property
    def is_active(self):
        return (self.revoked_at is None
                and self.record_type != MEMBERSHIP_RECORD_TYPE.INVITE)

    @is_active.expression
    def is_active(cls):  # NOQA: N805
        return db.and_(cls.revoked_at.is_(None),
                       cls.record_type != MEMBERSHIP_RECORD_TYPE.INVITE)

    with_roles(is_active, read={'subject'})

    @with_roles(read={'subject', 'editor'})
    @hybrid_property
    def is_invite(self):
        return self.record_type == MEMBERSHIP_RECORD_TYPE.INVITE

    @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):
        """Roles offered by this membership record"""
        return set()

    # Subclasses must gate these methods in __roles__

    @with_roles(call={'subject', 'editor'})
    def revoke(self, actor):
        if self.revoked_at is not None:
            raise MembershipRevokedError(
                "This membership record has already been revoked")
        self.revoked_at = db.func.utcnow()
        self.revoked_by = actor

    @with_roles(call={'editor'})
    def replace(self, actor, **roles):
        if self.revoked_at is not None:
            raise MembershipRevokedError(
                "This membership record has already been revoked")
        if not set(roles.keys()).issubset(self.__data_columns__):
            raise AttributeError("Unknown role")

        # Perform sanity check. If nothing changed, just return self
        has_changes = False
        if self.record_type == MEMBERSHIP_RECORD_TYPE.INVITE:
            # If we existing record is an INVITE, this must be an ACCEPT. This is an
            # acceptable change
            has_changes = True
        else:
            # If it's not an ACCEPT, are the supplied roles different from existing?
            for column in roles:
                if roles[column] != getattr(self, column):
                    has_changes = True
        if not has_changes:
            # Nothing is changing. This is probably a form submit with no changes.
            # Do nothing and return self
            return self

        # An actual change? Revoke this record and make a new record

        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)

        # if existing record type is INVITE, replace it with ACCEPT,
        # else replace it with AMEND
        if self.record_type == MEMBERSHIP_RECORD_TYPE.INVITE:
            new.record_type = MEMBERSHIP_RECORD_TYPE.ACCEPT
        else:
            new.record_type = MEMBERSHIP_RECORD_TYPE.AMEND

        for column in self.__data_columns__:
            if column in roles:
                setattr(new, column, roles[column])
            else:
                setattr(new, column, getattr(self, column))
        db.session.add(new)
        return new

    @with_roles(call={'subject'})
    def accept(self, actor):
        if self.record_type != MEMBERSHIP_RECORD_TYPE.INVITE:
            raise MembershipRecordTypeError(
                "This membership record is not an invite")
        return self.replace(actor)
Ejemplo n.º 18
0
class User(SharedProfileMixin, UuidMixin, BaseMixin, db.Model):
    __tablename__ = 'user'
    __title_length__ = 80

    # XXX: Deprecated, still here for Baseframe compatibility
    userid = db.synonym('buid')
    #: The user's fullname
    fullname = with_roles(
        db.Column(db.Unicode(__title_length__), default='', nullable=False),
        read={'all'},
    )
    #: Alias for the user's fullname
    title = db.synonym('fullname')
    #: Argon2 or Bcrypt hash of the user's password
    pw_hash = db.Column(db.Unicode, nullable=True)
    #: Timestamp for when the user's password last changed
    pw_set_at = db.Column(db.TIMESTAMP(timezone=True), nullable=True)
    #: Expiry date for the password (to prompt user to reset it)
    pw_expires_at = db.Column(db.TIMESTAMP(timezone=True), nullable=True)
    #: User's preferred/last known timezone
    timezone = with_roles(db.Column(TimezoneType(backend='pytz'),
                                    nullable=True),
                          read={'owner'})
    #: Update timezone automatically from browser activity
    auto_timezone = db.Column(db.Boolean, default=True, nullable=False)
    #: User's preferred/last known locale
    locale = with_roles(db.Column(LocaleType, nullable=True), read={'owner'})
    #: Update locale automatically from browser activity
    auto_locale = db.Column(db.Boolean, default=True, nullable=False)
    #: User's status (active, suspended, merged, etc)
    status = db.Column(db.SmallInteger,
                       nullable=False,
                       default=USER_STATUS.ACTIVE)

    #: Other user accounts that were merged into this user account
    oldusers = association_proxy('oldids', 'olduser')

    search_vector = db.deferred(
        db.Column(
            TSVectorType(
                'fullname',
                weights={'fullname': 'A'},
                regconfig='english',
                hltext=lambda: User.fullname,
            ),
            nullable=False,
        ))

    __table_args__ = (
        db.Index(
            'ix_user_fullname_lower',
            db.func.lower(fullname).label('fullname_lower'),
            postgresql_ops={'fullname_lower': 'varchar_pattern_ops'},
        ),
        db.Index('ix_user_search_vector',
                 'search_vector',
                 postgresql_using='gin'),
    )

    _defercols = [
        db.defer('created_at'),
        db.defer('updated_at'),
        db.defer('pw_hash'),
        db.defer('pw_set_at'),
        db.defer('pw_expires_at'),
        db.defer('timezone'),
    ]

    __roles__ = {
        'all': {
            'read': {
                'uuid',
                'name',
                'title',
                'fullname',
                'username',
                'pickername',
                'timezone',
                'status',
                'avatar',
                'created_at',
                'profile',
                'profile_url',
                'urls',
            },
            'call': {'views', 'forms', 'features', 'url_for'},
        }
    }

    __datasets__ = {
        'primary': {
            'uuid',
            'name',
            'title',
            'fullname',
            'username',
            'pickername',
            'timezone',
            'status',
            'avatar',
            'created_at',
            'profile',
            'profile_url',
            'urls',
        },
        'related': {
            'name',
            'title',
            'fullname',
            'username',
            'pickername',
            'timezone',
            'status',
            'avatar',
            'created_at',
            'profile_url',
        },
    }

    def __init__(self, password=None, **kwargs):
        self.password = password
        super(User, self).__init__(**kwargs)

    @hybrid_property
    def name(self):
        if self.profile:
            return self.profile.name

    @name.setter
    def name(self, value):
        if not value:
            if self.profile is not None:
                raise ValueError("Name is required")
        else:
            if self.profile is not None:
                self.profile.name = value
            else:
                self.profile = Profile(name=value, user=self, uuid=self.uuid)

    @name.expression
    def name(cls):  # NOQA: N805
        return db.select([Profile.name
                          ]).where(Profile.user_id == cls.id).label('name')

    with_roles(name, read={'all'})
    username = name

    @hybrid_property
    def is_active(self):
        return self.status == USER_STATUS.ACTIVE

    @cached_property
    def verified_contact_count(self):
        count = 0
        count += len(self.emails)
        count += len(self.phones)
        return count

    @property
    def has_verified_contact_info(self):
        return self.verified_contact_count > 0

    def merged_user(self):
        if self.status == USER_STATUS.MERGED:
            return UserOldId.get(self.uuid).user
        else:
            return self

    def _set_password(self, password):
        if password is None:
            self.pw_hash = None
        else:
            self.pw_hash = argon2.hash(password)
            # Also see :meth:`password_is` for transparent upgrade
        self.pw_set_at = db.func.utcnow()
        # Expire passwords after one year. TODO: make this configurable
        self.pw_expires_at = self.pw_set_at + timedelta(days=365)

    #: Write-only property (passwords cannot be read back in plain text)
    password = property(fset=_set_password)

    def password_has_expired(self):
        """True if password expiry timestamp has passed."""
        return (self.pw_hash is not None and self.pw_expires_at is not None
                and self.pw_expires_at <= utcnow())

    def password_is(self, password, upgrade_hash=False):
        """Test if the candidate password matches saved hash."""
        if self.pw_hash is None:
            return False

        # Passwords may use the current Argon2 scheme or the older Bcrypt scheme.
        # Bcrypt passwords are transparently upgraded if requested.
        if argon2.identify(self.pw_hash):
            return argon2.verify(password, self.pw_hash)
        elif bcrypt.identify(self.pw_hash):
            verified = bcrypt.verify(password, self.pw_hash)
            if verified and upgrade_hash:
                self.pw_hash = argon2.hash(password)
            return verified
        return False

    def __repr__(self):
        return '<User {username} "{fullname}">'.format(username=self.username
                                                       or self.buid,
                                                       fullname=self.fullname)

    def __str__(self):
        return self.pickername

    @with_roles(read={'all'})
    @property
    def pickername(self):
        if self.username:
            return '{fullname} (@{username})'.format(fullname=self.fullname,
                                                     username=self.username)
        else:
            return self.fullname

    def add_email(self,
                  email,
                  primary=False,
                  type=None,
                  private=False):  # NOQA: A002
        useremail = UserEmail(user=self,
                              email=email,
                              type=type,
                              private=private)
        useremail = failsafe_add(db.session,
                                 useremail,
                                 user=self,
                                 email_address=useremail.email_address)
        if primary:
            self.primary_email = useremail
        return useremail
        # FIXME: This should remove competing instances of UserEmailClaim

    def del_email(self, email):
        useremail = UserEmail.get_for(user=self, email=email)
        if self.primary_email in (useremail, None):
            self.primary_email = (UserEmail.query.filter(
                UserEmail.user == self, UserEmail.id != useremail.id).order_by(
                    UserEmail.created_at.desc()).first())
        db.session.delete(useremail)

    @with_roles(read={'owner'})
    @cached_property
    def email(self):
        """
        Returns primary email address for user.
        """
        # Look for a primary address
        useremail = self.primary_email
        if useremail:
            return useremail
        # No primary? Maybe there's one that's not set as primary?
        useremail = UserEmail.query.filter_by(user=self).first()
        if useremail:
            # XXX: Mark as primary. This may or may not be saved depending on
            # whether the request ended in a database commit.
            self.primary_email = useremail
            return useremail
        # This user has no email address. Return a blank string instead of None
        # to support the common use case, where the caller will use str(user.email)
        # to get the email address as a string.
        return ''

    @with_roles(read={'owner'})
    @cached_property
    def phone(self):
        """
        Returns primary phone number for user.
        """
        # Look for a primary phone number
        userphone = self.primary_phone
        if userphone:
            return userphone
        # No primary? Maybe there's one that's not set as primary?
        userphone = UserPhone.query.filter_by(user=self).first()
        if userphone:
            # XXX: Mark as primary. This may or may not be saved depending on
            # whether the request ended in a database commit.
            self.primary_phone = userphone
            return userphone
        # This user has no phone number. Return a blank string instead of None
        # to support the common use case, where the caller will use str(user.phone)
        # to get the phone number as a string.
        return ''

    def is_profile_complete(self):
        """
        Return True if profile is complete (fullname, username and one contact are
        present), False otherwise.
        """
        return bool(self.fullname and self.username
                    and self.has_verified_contact_info)

    # --- Transport details

    @with_roles(call={'owner'})
    def has_transport_email(self):
        return self.is_active and bool(self.email)

    @with_roles(call={'owner'})
    def has_transport_sms(self):
        return self.is_active and bool(self.phone)

    @with_roles(call={'owner'})
    def has_transport_webpush(self):  # TODO  # pragma: no cover
        return False

    @with_roles(call={'owner'})
    def has_transport_telegram(self):  # TODO  # pragma: no cover
        return False

    @with_roles(call={'owner'})
    def has_transport_whatsapp(self):  # TODO  # pragma: no cover
        return False

    @with_roles(call={'owner'})
    def transport_for_email(self, context):
        """Return user's preferred email address within a context."""
        # Per-profile/project customization is a future option
        return self.email if self.is_active else None

    @with_roles(call={'owner'})
    def transport_for_sms(self, context):
        """Return user's preferred phone number within a context."""
        # Per-profile/project customization is a future option
        return self.phone if self.is_active else None

    @with_roles(call={'owner'})
    def transport_for_webpush(self, context):  # TODO  # pragma: no cover
        return None

    @with_roles(call={'owner'})
    def transport_for_telegram(self, context):  # TODO  # pragma: no cover
        return None

    @with_roles(call={'owner'})
    def transport_for_whatsapp(self, context):  # TODO  # pragma: no cover
        return None

    @with_roles(call={'owner'})
    def has_transport(self, transport):
        """
        Helper method to call ``self.has_transport_<transport>()``.

        ..note::
            Because this method does not accept a context, it may return True for a
            transport that has been muted in that context. This may cause an empty
            background job to be queued for a notification. Revisit this method when
            preference contexts are supported.
        """
        return getattr(self, 'has_transport_' + transport)()

    @with_roles(call={'owner'})
    def transport_for(self, transport, context):
        """Helper method to call ``self.transport_for_<transport>(context)``."""
        return getattr(self, 'transport_for_' + transport)(context)

    @with_roles(grants={'owner', 'admin'})
    @property
    def _self_is_owner_and_admin_of_self(self):
        """Helper method for ``roles_for`` and ``actors_with``."""
        return self

    def organizations_as_owner_ids(self):
        """
        Return the database ids of the organizations this user is an owner of. This is
        used for database queries.
        """
        return [
            membership.organization_id
            for membership in self.active_organization_owner_memberships
        ]

    @classmethod
    def get(cls, username=None, buid=None, userid=None, defercols=False):
        """
        Return a User with the given username or buid.

        :param str username: Username to lookup
        :param str buid: Buid to lookup
        :param bool defercols: Defer loading non-critical columns
        """
        require_one_of(username=username, buid=buid, userid=userid)

        # userid parameter is temporary for Flask-Lastuser compatibility
        if userid:
            buid = userid

        if username is not None:
            query = cls.query.join(Profile).filter(
                db.func.lower(Profile.name) == db.func.lower(username))
        else:
            query = cls.query.filter_by(buid=buid)
        if defercols:
            query = query.options(*cls._defercols)
        user = query.one_or_none()
        if user and user.status == USER_STATUS.MERGED:
            user = user.merged_user()
        if user and user.is_active:
            return user

    @classmethod  # NOQA: A003
    def all(  # NOQA: A003
            cls,
            buids=None,
            userids=None,
            usernames=None,
            defercols=False):
        """
        Return all matching users.

        :param list buids: Buids to look up
        :param list userids: Alias for ``buids`` (deprecated)
        :param list usernames: Usernames to look up
        :param bool defercols: Defer loading non-critical columns
        """
        users = set()
        if userids and not buids:
            buids = userids
        if buids and usernames:
            query = cls.query.join(Profile).filter(
                db.or_(
                    cls.buid.in_(buids),
                    db.func.lower(Profile.name).in_(
                        [username.lower() for username in usernames]),
                ))
        elif buids:
            query = cls.query.filter(cls.buid.in_(buids))
        elif usernames:
            query = cls.query.join(Profile).filter(
                db.func.lower(Profile.name).in_(
                    [username.lower() for username in usernames]))
        else:
            raise Exception

        if defercols:
            query = query.options(*cls._defercols)
        for user in query.all():
            user = user.merged_user()
            if user.is_active:
                users.add(user)
        return list(users)

    @classmethod
    def autocomplete(cls, query):
        """
        Return users whose names begin with the query, for autocomplete widgets.
        Looks up users by fullname, username, external ids and email addresses.

        :param str query: Letters to start matching with
        """
        # Escape the '%' and '_' wildcards in SQL LIKE clauses.
        # Some SQL dialects respond to '[' and ']', so remove them.
        like_query = (query.replace('%', r'\%').replace('_', r'\_').replace(
            '[', '').replace(']', '') + '%')

        # We convert to lowercase and use the LIKE operator since ILIKE isn't standard
        # and doesn't use an index in PostgreSQL. There's a functional index for lower()
        # defined above in __table_args__ that also applies to LIKE lower(val) queries.

        if like_query in ('%', '@%'):
            return []

        # base_users is used in two of the three possible queries below
        base_users = (cls.query.join(Profile).filter(
            cls.status == USER_STATUS.ACTIVE,
            db.or_(
                db.func.lower(cls.fullname).like(db.func.lower(like_query)),
                db.func.lower(Profile.name).like(db.func.lower(like_query)),
            ),
        ).options(*cls._defercols).limit(20))

        if (query != '@' and query.startswith('@')
                and UserExternalId.__at_username_services__):
            # @-prefixed, so look for usernames, including other @username-using
            # services like Twitter and GitHub. Make a union of three queries.
            users = (
                # Query 1: @query -> User.username
                cls.query.join(Profile).filter(
                    cls.status == USER_STATUS.ACTIVE,
                    db.func.lower(Profile.name).like(
                        db.func.lower(like_query[1:])),
                ).options(*cls._defercols).limit(20).union(
                    # Query 2: @query -> UserExternalId.username
                    cls.query.join(UserExternalId).filter(
                        cls.status == USER_STATUS.ACTIVE,
                        UserExternalId.service.in_(
                            UserExternalId.__at_username_services__),
                        db.func.lower(UserExternalId.username).like(
                            db.func.lower(like_query[1:])),
                    ).options(*cls._defercols).limit(20),
                    # Query 3: like_query -> User.fullname
                    cls.query.filter(
                        cls.status == USER_STATUS.ACTIVE,
                        db.func.lower(cls.fullname).like(
                            db.func.lower(like_query)),
                    ).options(*cls._defercols).limit(20),
                ).all())
        elif '@' in query and not query.startswith('@'):
            # Query has an @ in the middle. Match email address (exact match only).
            # Use `query` instead of `like_query` because it's not a LIKE query.
            # Combine results with regular user search
            users = (cls.query.join(UserEmail, EmailAddress).filter(
                UserEmail.user_id == cls.id,
                UserEmail.email_address_id == EmailAddress.id,
                EmailAddress.get_filter(email=query),
                cls.status == USER_STATUS.ACTIVE,
            ).options(*cls._defercols).limit(20).union(base_users).all())
        else:
            # No '@' in the query, so do a regular autocomplete
            users = base_users.all()
        return users

    @classmethod
    def active_user_count(cls):
        return cls.query.filter_by(status=USER_STATUS.ACTIVE).count()

    #: FIXME: Temporary values for Baseframe compatibility
    def organization_links(self):
        return []
Ejemplo n.º 19
0
class Team(UuidMixin, BaseMixin, db.Model):
    __tablename__ = 'team'
    __title_length__ = 250
    #: Displayed name
    title = db.Column(db.Unicode(__title_length__), nullable=False)
    #: Organization
    organization_id = db.Column(None,
                                db.ForeignKey('organization.id'),
                                nullable=False)
    organization = with_roles(
        db.relationship(
            Organization,
            backref=db.backref('teams',
                               order_by=db.func.lower(title),
                               cascade='all'),
        ),
        grants_via={None: {
            'owner': 'owner',
            'admin': 'admin'
        }},
    )
    users = with_roles(
        db.relationship(User,
                        secondary=team_membership,
                        lazy='dynamic',
                        backref='teams'),
        grants={'subject'},
    )

    is_public = db.Column(db.Boolean, nullable=False, default=False)

    def __repr__(self):
        return '<Team {team} of {organization}>'.format(
            team=self.title, organization=repr(self.organization)[1:-1])

    @property
    def pickername(self):
        return self.title

    def permissions(self, user, inherited=None):
        perms = super(Team, self).permissions(user, inherited)
        if user and user in self.organization.admin_users:
            perms.add('edit')
            perms.add('delete')
        return perms

    @classmethod
    def migrate_user(cls, olduser, newuser):
        for team in list(olduser.teams):
            if team not in newuser.teams:
                # FIXME: This creates new memberships, updating `created_at`.
                # Unfortunately, we can't work with model instances as in the other
                # `migrate_user` methods as team_membership is an unmapped table.
                newuser.teams.append(team)
            olduser.teams.remove(team)
        return [cls.__table__.name, team_membership.name]

    @classmethod
    def get(cls, buid, with_parent=False):
        """
        Return a Team with matching buid.

        :param str buid: Buid of the team
        """
        if with_parent:
            query = cls.query.options(db.joinedload(cls.organization))
        else:
            query = cls.query
        return query.filter_by(buid=buid).one_or_none()
Ejemplo n.º 20
0
class Organization(SharedProfileMixin, UuidMixin, BaseMixin, db.Model):
    __tablename__ = 'organization'
    __title_length__ = 80

    title = with_roles(
        db.Column(db.Unicode(__title_length__), default='', nullable=False),
        read={'all'},
    )

    search_vector = db.deferred(
        db.Column(
            TSVectorType(
                'title',
                weights={'title': 'A'},
                regconfig='english',
                hltext=lambda: Organization.title,
            ),
            nullable=False,
        ))

    __table_args__ = (db.Index('ix_organization_search_vector',
                               'search_vector',
                               postgresql_using='gin'), )

    __roles__ = {
        'all': {
            'read': {
                'name',
                'title',
                'pickername',
                'created_at',
                'profile',
                'profile_url',
                'urls',
            },
            'call': {'views', 'features', 'forms', 'url_for'},
        }
    }

    __datasets__ = {
        'primary': {
            'name',
            'title',
            'username',
            'pickername',
            'avatar',
            'created_at',
            'profile',
            'profile_url',
        },
        'related': {'name', 'title', 'pickername', 'created_at'},
    }

    _defercols = [db.defer('created_at'), db.defer('updated_at')]

    def __init__(self, owner, *args, **kwargs):
        super(Organization, self).__init__(*args, **kwargs)
        db.session.add(
            OrganizationMembership(organization=self,
                                   user=owner,
                                   granted_by=owner,
                                   is_owner=True))

    @hybrid_property
    def name(self):
        if self.profile:
            return self.profile.name

    @name.setter
    def name(self, value):
        if not value:
            if self.profile is not None:
                raise ValueError("Name is required")
        else:
            if self.profile is not None:
                self.profile.name = value
            else:
                self.profile = Profile(name=value,
                                       organization=self,
                                       uuid=self.uuid)

    @name.expression
    def name(cls):  # NOQA: N805
        return (db.select([
            Profile.name
        ]).where(Profile.organization_id == cls.id).label('name'))

    with_roles(name, read={'all'})

    def __repr__(self):
        return '<Organization {name} "{title}">'.format(name=self.name
                                                        or self.buid,
                                                        title=self.title)

    @with_roles(read={'all'})
    @property
    def pickername(self):
        if self.name:
            return '{title} (@{name})'.format(title=self.title, name=self.name)
        else:
            return self.title

    def people(self):
        """Return a list of users from across the public teams they are in."""
        return (User.query.join(team_membership).join(Team).filter(
            Team.organization == self,
            Team.is_public.is_(True)).options(db.joinedload(
                User.teams)).order_by(db.func.lower(User.fullname)))

    def permissions(self, user, inherited=None):
        perms = super().permissions(user, inherited)
        if 'view' in perms:
            perms.remove('view')
        if 'edit' in perms:
            perms.remove('edit')
        if 'delete' in perms:
            perms.remove('delete')

        if user and user in self.admin_users:
            perms.add('view')
            perms.add('edit')
            perms.add('view-teams')
            perms.add('new-team')
        if user and user in self.owner_users:
            perms.add('delete')
        return perms

    @classmethod
    def get(cls, name=None, buid=None, defercols=False):
        """
        Return an Organization with matching name or buid. Note that ``name`` is the username, not the title.

        :param str name: Name of the organization
        :param str buid: Buid of the organization
        :param bool defercols: Defer loading non-critical columns
        """
        require_one_of(name=name, buid=buid)

        if name is not None:
            query = cls.query.join(Profile).filter(
                db.func.lower(Profile.name) == db.func.lower(name))
        else:
            query = cls.query.filter_by(buid=buid)
        if defercols:
            query = query.options(*cls._defercols)
        return query.one_or_none()

    @classmethod  # NOQA: A003
    def all(cls, buids=None, names=None, defercols=False):  # NOQA: A003
        orgs = []
        if buids:
            query = cls.query.filter(cls.buid.in_(buids))
            if defercols:
                query = query.options(*cls._defercols)
            orgs.extend(query.all())
        if names:
            query = cls.query.join(Profile).filter(
                db.func.lower(Profile.name).in_(
                    [name.lower() for name in names]))
            if defercols:
                query = query.options(*cls._defercols)
            orgs.extend(query.all())
        return orgs
Ejemplo n.º 21
0
class Rsvp(UuidMixin, NoIdMixin, db.Model):
    __tablename__ = 'rsvp'
    project_id = db.Column(None,
                           db.ForeignKey('project.id'),
                           nullable=False,
                           primary_key=True)
    project = with_roles(
        db.relationship(Project,
                        backref=db.backref('rsvps',
                                           cascade='all',
                                           lazy='dynamic')),
        read={'owner', 'project_concierge'},
        grants_via={None: project_child_role_map},
    )
    user_id = db.Column(None,
                        db.ForeignKey('user.id'),
                        nullable=False,
                        primary_key=True)
    user = with_roles(
        db.relationship(User,
                        backref=db.backref('rsvps',
                                           cascade='all',
                                           lazy='dynamic')),
        read={'owner', 'project_concierge'},
        grants={'owner'},
    )

    _state = db.Column(
        'state',
        db.CHAR(1),
        StateManager.check_constraint('state', RSVP_STATUS),
        default=RSVP_STATUS.AWAITING,
        nullable=False,
    )
    state = with_roles(
        StateManager('_state', RSVP_STATUS, doc="RSVP answer"),
        call={'owner', 'project_concierge'},
    )

    __datasets__ = {
        'primary': {'project', 'user', 'response'},
        'related': {'response'}
    }

    @with_roles(read={'owner', 'project_concierge'})
    @property
    def response(self):
        """Return state as a raw value"""
        return self._state

    @with_roles(call={'owner'})
    @state.transition(
        None,
        state.YES,
        title=__("Going"),
        message=__("Your response has been saved"),
        type='primary',
    )
    def rsvp_yes(self):
        pass

    @with_roles(call={'owner'})
    @state.transition(
        None,
        state.NO,
        title=__("Not going"),
        message=__("Your response has been saved"),
        type='dark',
    )
    def rsvp_no(self):
        pass

    @with_roles(call={'owner'})
    @state.transition(
        None,
        state.MAYBE,
        title=__("Maybe"),
        message=__("Your response has been saved"),
        type='accent',
    )
    def rsvp_maybe(self):
        pass

    @with_roles(call={'owner', 'project_concierge'})
    def user_email(self):
        """User's preferred email address for this registration."""
        return self.user.transport_for_email(self.project.profile)

    @classmethod
    def migrate_user(cls, old_user, new_user):
        project_ids = {rsvp.project_id for rsvp in new_user.rsvps}
        for rsvp in old_user.rsvps:
            if rsvp.project_id not in project_ids:
                rsvp.user = new_user
            else:
                current_app.logger.warning(
                    "Discarding conflicting RSVP (%s) from %r on %r",
                    rsvp._state,
                    old_user,
                    rsvp.project,
                )
                db.session.delete(rsvp)

    @classmethod
    def get_for(cls, project, user, create=False):
        if user:
            result = cls.query.get((project.id, user.id))
            if not result and create:
                result = cls(project=project, user=user)
                db.session.add(result)
            return result
Ejemplo n.º 22
0
 def mixed_in1(cls):
     return with_roles(db.Column(db.Unicode(250)), rw={'owner'})
Ejemplo n.º 23
0
    def secret_is(self, candidate, name):
        """
        Check if the provided client secret is valid.
        """
        credential = self.credentials[name]
        return credential.secret_is(candidate)

    @property
    def redirect_uris(self):
        return tuple(self._redirect_uris.split())

    @redirect_uris.setter
    def redirect_uris(self, value):
        self._redirect_uris = '\r\n'.join(value)

    with_roles(redirect_uris, rw={'owner'})

    @property
    def redirect_uri(self):
        uris = self.redirect_uris  # Assign to local var to avoid splitting twice
        if uris:
            return uris[0]

    def host_matches(self, url):
        netloc = urllib.parse.urlsplit(url or '').netloc
        if netloc:
            return netloc in (urllib.parse.urlsplit(r).netloc
                              for r in (self.redirect_uris + (self.website, )))
        return False

    @with_roles(read={'all'})
Ejemplo n.º 24
0
 def mixed_in1(cls):
     return with_roles(db.Column(db.Unicode(250)),
         rw={'owner'})
Ejemplo n.º 25
0
class Project:
    # Project schedule column expressions
    # Guide: https://docs.sqlalchemy.org/en/13/orm/mapped_sql_expr.html#using-column-property
    schedule_start_at = with_roles(
        db.column_property(
            db.select([
                db.func.min(Session.start_at)
            ]).where(Session.start_at.isnot(None)).where(
                Session.project_id == Project.id).correlate_except(Session)),
        read={'all'},
        datasets={'primary', 'without_parent'},
    )

    next_session_at = with_roles(
        db.column_property(
            db.select([db.func.min(Session.start_at)
                       ]).where(Session.start_at.isnot(None)).
            where(Session.start_at > db.func.utcnow()).where(
                Session.project_id == Project.id).correlate_except(Session)),
        read={'all'},
    )

    schedule_end_at = with_roles(
        db.column_property(
            db.select([
                db.func.max(Session.end_at)
            ]).where(Session.end_at.isnot(None)).where(
                Session.project_id == Project.id).correlate_except(Session)),
        read={'all'},
        datasets={'primary', 'without_parent'},
    )

    @with_roles(read={'all'}, datasets={'primary', 'without_parent'})
    @cached_property
    def schedule_start_at_localized(self):
        return (localize_timezone(self.schedule_start_at, tz=self.timezone)
                if self.schedule_start_at else None)

    @with_roles(read={'all'}, datasets={'primary', 'without_parent'})
    @cached_property
    def schedule_end_at_localized(self):
        return (localize_timezone(self.schedule_end_at, tz=self.timezone)
                if self.schedule_end_at else None)

    @with_roles(read={'all'})
    @cached_property
    def session_count(self):
        return self.sessions.filter(Session.start_at.isnot(None)).count()

    featured_sessions = with_roles(
        db.relationship(
            Session,
            order_by=Session.start_at.asc(),
            primaryjoin=db.and_(Session.project_id == Project.id,
                                Session.featured.is_(True)),
        ),
        read={'all'},
    )
    scheduled_sessions = with_roles(
        db.relationship(
            Session,
            order_by=Session.start_at.asc(),
            primaryjoin=db.and_(Session.project_id == Project.id,
                                Session.scheduled),
        ),
        read={'all'},
    )
    unscheduled_sessions = with_roles(
        db.relationship(
            Session,
            order_by=Session.start_at.asc(),
            primaryjoin=db.and_(Session.project_id == Project.id,
                                Session.scheduled.isnot(True)),
        ),
        read={'all'},
    )

    sessions_with_video = with_roles(
        db.relationship(
            Session,
            lazy='dynamic',
            primaryjoin=db.and_(
                Project.id == Session.project_id,
                Session.video_id.isnot(None),
                Session.video_source.isnot(None),
            ),
        ),
        read={'all'},
    )

    @with_roles(read={'all'})
    @cached_property
    def has_sessions_with_video(self):
        return self.query.session.query(
            self.sessions_with_video.exists()).scalar()

    def next_session_from(self, timestamp):
        """
        Find the next session in this project starting at or after given timestamp.
        """
        return (self.sessions.filter(Session.start_at.isnot(None),
                                     Session.start_at >= timestamp).order_by(
                                         Session.start_at.asc()).first())

    @classmethod
    def starting_at(cls, timestamp, within, gap):
        """
        Returns projects that are about to start, for sending notifications.

        :param datetime timestamp: The timestamp to look for new sessions at
        :param timedelta within: Find anything at timestamp + within delta. Lookup will
            be for sessions where timestamp >= start_at < timestamp+within
        :param timedelta gap: A project will be considered to be starting if it has no
            sessions ending within the gap period before the timestamp

        Typical use of this method is from a background worker that calls it at
        intervals of five minutes with parameters (timestamp, within 5m, 60m gap).
        """
        # As a rule, start_at is queried with >= and <, end_at with > and <= because
        # they represent inclusive lower and upper bounds.
        return (cls.query.filter(
            cls.id.in_(
                db.session.query(db.func.distinct(Session.project_id)).filter(
                    Session.start_at.isnot(None),
                    Session.start_at >= timestamp,
                    Session.start_at < timestamp + within,
                    Session.project_id.notin_(
                        db.session.query(db.func.distinct(
                            Session.project_id)).filter(
                                Session.start_at.isnot(None),
                                db.or_(
                                    db.and_(
                                        Session.start_at >= timestamp - gap,
                                        Session.start_at < timestamp,
                                    ),
                                    db.and_(
                                        Session.end_at > timestamp - gap,
                                        Session.end_at <= timestamp,
                                    ),
                                ),
                            )),
                ))).join(Session.project).filter(
                    Project.state.PUBLISHED, Project.schedule_state.PUBLISHED))

    @with_roles(call={'all'})
    def current_sessions(self):
        if self.schedule_start_at is None or (
                self.schedule_start_at > utcnow() + timedelta(minutes=30)):
            return

        current_sessions = (self.sessions.outerjoin(VenueRoom).filter(
            Session.start_at <= db.func.utcnow() +
            timedelta(minutes=30)).filter(
                Session.end_at > db.func.utcnow()).order_by(
                    Session.start_at.asc(), VenueRoom.seq.asc()))

        return {
            'sessions': [
                session.current_access(datasets=('without_parent', 'related'))
                for session in current_sessions
            ],
            'rooms': [
                room.current_access(datasets=('without_parent', 'related'))
                for room in self.rooms
            ],
        }

    def calendar_weeks(self, leading_weeks=True):
        # session_dates is a list of tuples in this format -
        # (date, day_start_at, day_end_at, event_count)
        session_dates = list(
            db.session.query('date', 'day_start_at', 'day_end_at',
                             'count').from_statement(
                                 db.text('''
                    SELECT
                        DATE_TRUNC('day', "start_at" AT TIME ZONE :timezone) AS date,
                        MIN(start_at) as day_start_at,
                        MAX(end_at) as day_end_at,
                        COUNT(*) AS count
                    FROM "session" WHERE "project_id" = :project_id AND "start_at" IS NOT NULL AND "end_at" IS NOT NULL
                    GROUP BY date ORDER BY date;
                    ''')).params(timezone=self.timezone.zone,
                                 project_id=self.id))

        session_dates_dict = {
            date.date(): {
                'day_start_at': day_start_at,
                'day_end_at': day_end_at,
                'count': count,
            }
            for date, day_start_at, day_end_at, count in session_dates
        }

        # FIXME: This doesn't work. This code needs to be tested in isolation
        # session_dates = db.session.query(
        #     db.cast(
        #         db.func.date_trunc('day', db.func.timezone(self.timezone.zone, Session.start_at)),
        #         db.Date).label('date'),
        #     db.func.count().label('count')
        #     ).filter(
        #         Session.project == self,
        #         Session.scheduled
        #         ).group_by(db.text('date')).order_by(db.text('date'))

        # if the project's week is within next 2 weeks, send current week as well
        now = utcnow().astimezone(self.timezone)
        current_week = Week.withdate(now)

        if leading_weeks and self.schedule_start_at is not None:
            schedule_start_week = Week.withdate(self.schedule_start_at)

            # session_dates is a list of tuples in this format -
            # (date, day_start_at, day_end_at, event_count)
            # as these days dont have any event, day_start/end_at are None,
            # and count is 0.
            if (schedule_start_week > current_week
                    and (schedule_start_week - current_week) <= 2):
                if (schedule_start_week - current_week) == 2:
                    # add this so that the next week's dates
                    # are also included in the calendar.
                    session_dates.insert(
                        0, (now + timedelta(days=7), None, None, 0))
                session_dates.insert(0, (now, None, None, 0))

        weeks = defaultdict(dict)
        today = now.date()
        for project_date, _day_start_at, _day_end_at, session_count in session_dates:
            weekobj = Week.withdate(project_date)
            if weekobj.week not in weeks:
                weeks[weekobj.week]['year'] = weekobj.year
                # Order is important, and we need dict to count easily
                weeks[weekobj.week]['dates'] = OrderedDict()
            for wdate in weekobj.days():
                weeks[weekobj.week]['dates'].setdefault(wdate, 0)
                if project_date.date() == wdate:
                    # If the event is over don't set upcoming for current week
                    if wdate >= today and weekobj >= current_week and session_count > 0:
                        weeks[weekobj.week]['upcoming'] = True
                    weeks[weekobj.week]['dates'][wdate] += session_count
                    if 'month' not in weeks[weekobj.week]:
                        weeks[weekobj.week]['month'] = format_date(
                            wdate, 'MMM', locale=get_locale())

        # Extract sorted weeks as a list
        weeks_list = [v for k, v in sorted(weeks.items())]

        for week in weeks_list:
            # Convering to JSON messes up dictionary key order even though we used OrderedDict.
            # This turns the OrderedDict into a list of tuples and JSON preserves that order.
            week['dates'] = [{
                'isoformat':
                date.isoformat(),
                'day':
                format_date(date, 'd', get_locale()),
                'count':
                count,
                'day_start_at':
                (session_dates_dict[date]['day_start_at'].astimezone(
                    self.timezone).strftime('%I:%M %p')
                 if date in session_dates_dict.keys() else None),
                'day_end_at':
                (session_dates_dict[date]['day_end_at'].astimezone(
                    self.timezone).strftime('%I:%M %p %Z')
                 if date in session_dates_dict.keys() else None),
            } for date, count in week['dates'].items()]

        return {
            'locale':
            get_locale(),
            'weeks':
            weeks_list,
            'today':
            now.date().isoformat(),
            'days': [
                format_date(day, 'EEE', locale=get_locale())
                for day in Week.thisweek().days()
            ],
        }

    @with_roles(read={'all'}, datasets={'primary', 'without_parent'})
    @cached_property
    def calendar_weeks_full(self):
        return self.calendar_weeks(leading_weeks=True)

    @with_roles(read={'all'}, datasets={'primary', 'without_parent'})
    @cached_property
    def calendar_weeks_compact(self):
        return self.calendar_weeks(leading_weeks=False)
Ejemplo n.º 26
0
class Session(UuidMixin, BaseScopedIdNameMixin, VideoMixin, db.Model):
    __tablename__ = 'session'

    project_id = db.Column(None, db.ForeignKey('project.id'), nullable=False)
    project = with_roles(
        db.relationship(Project,
                        backref=db.backref('sessions',
                                           cascade='all',
                                           lazy='dynamic')),
        grants_via={None: project_child_role_map},
    )
    parent = db.synonym('project')
    description = MarkdownColumn('description', default='', nullable=False)
    speaker_bio = MarkdownColumn('speaker_bio', default='', nullable=False)
    proposal_id = db.Column(None,
                            db.ForeignKey('proposal.id'),
                            nullable=True,
                            unique=True)
    proposal = db.relationship(Proposal,
                               backref=db.backref('session',
                                                  uselist=False,
                                                  cascade='all'))
    speaker = db.Column(db.Unicode(200), default=None, nullable=True)
    start_at = db.Column(db.TIMESTAMP(timezone=True),
                         nullable=True,
                         index=True)
    end_at = db.Column(db.TIMESTAMP(timezone=True), nullable=True, index=True)
    venue_room_id = db.Column(None,
                              db.ForeignKey('venue_room.id'),
                              nullable=True)
    venue_room = db.relationship(VenueRoom, backref=db.backref('sessions'))
    is_break = db.Column(db.Boolean, default=False, nullable=False)
    featured = db.Column(db.Boolean, default=False, nullable=False)
    banner_image_url = db.Column(UrlType, nullable=True)

    search_vector = db.deferred(
        db.Column(
            TSVectorType(
                'title',
                'description_text',
                'speaker_bio_text',
                'speaker',
                weights={
                    'title': 'A',
                    'description_text': 'B',
                    'speaker_bio_text': 'B',
                    'speaker': 'A',
                },
                regconfig='english',
                hltext=lambda: db.func.concat_ws(
                    visual_field_delimiter,
                    Session.title,
                    Session.speaker,
                    Session.description_html,
                    Session.speaker_bio_html,
                ),
            ),
            nullable=False,
        ))

    __table_args__ = (
        db.UniqueConstraint('project_id', 'url_id'),
        db.CheckConstraint(
            '("start_at" IS NULL AND "end_at" IS NULL) OR ("start_at" IS NOT NULL AND "end_at" IS NOT NULL)',
            'session_start_at_end_at_check',
        ),
        db.Index('ix_session_search_vector',
                 'search_vector',
                 postgresql_using='gin'),
    )

    __roles__ = {
        'all': {
            'read': {
                'created_at',
                'updated_at',
                'title',
                'project',
                'speaker',
                'user',
                'featured',
                'description',
                'speaker_bio',
                'start_at',
                'end_at',
                'venue_room',
                'is_break',
                'banner_image_url',
                'start_at_localized',
                'end_at_localized',
                'scheduled',
                'video',
                'proposal',
            },
            'call': {'url_for'},
        }
    }

    __datasets__ = {
        'primary': {
            'uuid_b58',
            'title',
            'speaker',
            'user',
            'featured',
            'description',
            'speaker_bio',
            'start_at',
            'end_at',
            'venue_room',
            'is_break',
            'banner_image_url',
            'start_at_localized',
            'end_at_localized',
        },
        'without_parent': {
            'uuid_b58',
            'title',
            'speaker',
            'user',
            'featured',
            'description',
            'speaker_bio',
            'start_at',
            'end_at',
            'venue_room',
            'is_break',
            'banner_image_url',
            'start_at_localized',
            'end_at_localized',
        },
        'related': {
            'uuid_b58',
            'title',
            'speaker',
            'user',
            'featured',
            'description',
            'speaker_bio',
            'start_at',
            'end_at',
            'venue_room',
            'is_break',
            'banner_image_url',
            'start_at_localized',
            'end_at_localized',
        },
    }

    @hybrid_property
    def user(self):
        if self.proposal:
            return self.proposal.speaker

    @hybrid_property
    def scheduled(self):
        # A session is scheduled only when both start and end fields have a value
        return self.start_at is not None and self.end_at is not None

    @scheduled.expression
    def scheduled(self):
        return (self.start_at.isnot(None)) & (self.end_at.isnot(None))

    @cached_property
    def start_at_localized(self):
        return (localize_timezone(self.start_at, tz=self.project.timezone)
                if self.start_at else None)

    @cached_property
    def end_at_localized(self):
        return (localize_timezone(self.end_at, tz=self.project.timezone)
                if self.end_at else None)

    @classmethod
    def for_proposal(cls, proposal, create=False):
        session_obj = cls.query.filter_by(proposal=proposal).first()
        if session_obj is None and create:
            session_obj = cls(
                title=proposal.title,
                description=proposal.outline,
                speaker_bio=proposal.bio,
                project=proposal.project,
                proposal=proposal,
            )
            db.session.add(session_obj)
        return session_obj

    def make_unscheduled(self):
        # Session is not deleted, but we remove start and end time,
        # so it becomes an unscheduled session.
        self.start_at = None
        self.end_at = None
Ejemplo n.º 27
0
class AuthToken(ScopeMixin, BaseMixin, db.Model):
    """Access tokens for access to data"""

    __tablename__ = 'auth_token'
    # Null for client-only tokens and public clients (user is identified via user_session.user there)
    user_id = db.Column(None, db.ForeignKey('user.id'), nullable=True)
    _user = db.relationship(
        User,
        primaryjoin=user_id == User.id,
        backref=db.backref('authtokens', lazy='dynamic', cascade='all'),
    )
    #: The session in which this token was issued, null for confidential clients
    user_session_id = db.Column(None,
                                db.ForeignKey('user_session.id'),
                                nullable=True)
    user_session = with_roles(
        db.relationship(UserSession,
                        backref=db.backref('authtokens', lazy='dynamic')),
        read={'owner'},
    )
    #: The client this authtoken is for
    auth_client_id = db.Column(None,
                               db.ForeignKey('auth_client.id'),
                               nullable=False,
                               index=True)
    auth_client = with_roles(
        db.relationship(
            AuthClient,
            primaryjoin=auth_client_id == AuthClient.id,
            backref=db.backref('authtokens', lazy='dynamic', cascade='all'),
        ),
        read={'owner'},
    )
    #: The token
    token = db.Column(db.String(22), default=buid, nullable=False, unique=True)
    #: The token's type
    token_type = db.Column(db.String(250), default='bearer',
                           nullable=False)  # 'bearer', 'mac' or a URL
    #: Token secret for 'mac' type
    secret = db.Column(db.String(44), nullable=True)
    #: Secret's algorithm (for 'mac' type)
    _algorithm = db.Column('algorithm', db.String(20), nullable=True)
    #: Token's validity, 0 = unlimited
    validity = db.Column(db.Integer, nullable=False,
                         default=0)  # Validity period in seconds
    #: Refresh token, to obtain a new token
    refresh_token = db.Column(db.String(22), nullable=True, unique=True)

    # Only one authtoken per user and client. Add to scope as needed
    __table_args__ = (
        db.UniqueConstraint('user_id', 'auth_client_id'),
        db.UniqueConstraint('user_session_id', 'auth_client_id'),
    )

    __roles__ = {'owner': {'read': {'created_at'}}}

    @property
    def user(self):
        if self.user_session:
            return self.user_session.user
        else:
            return self._user

    @user.setter
    def user(self, value):
        self._user = value

    user = with_roles(db.synonym('_user', descriptor=user),
                      read={'owner'},
                      grants={'owner'})

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.token = buid()
        if self._user:
            self.refresh_token = buid()
        self.secret = newsecret()

    def __repr__(self):
        return '<AuthToken {token} of {auth_client} {user}>'.format(
            token=self.token,
            auth_client=repr(self.auth_client)[1:-1],
            user=repr(self.user)[1:-1],
        )

    @property
    def effective_scope(self):
        return sorted(set(self.scope) | set(self.auth_client.scope))

    @with_roles(read={'owner'})
    @cached_property
    def last_used(self):
        return (db.session.query(
            db.func.max(auth_client_user_session.c.accessed_at)
        ).select_from(auth_client_user_session, UserSession).filter(
            auth_client_user_session.c.user_session_id == UserSession.id,
            auth_client_user_session.c.auth_client_id == self.auth_client_id,
            UserSession.user == self.user,
        ).scalar())

    def refresh(self):
        """
        Create a new token while retaining the refresh token.
        """
        if self.refresh_token is not None:
            self.token = buid()
            self.secret = newsecret()

    @property
    def algorithm(self):
        return self._algorithm

    @algorithm.setter
    def algorithm(self, value):
        if value is None:
            self._algorithm = None
            self.secret = None
        elif value in ['hmac-sha-1', 'hmac-sha-256']:
            self._algorithm = value
        else:
            raise ValueError(
                _("Unrecognized algorithm ‘{value}’").format(value=value))

    algorithm = db.synonym('_algorithm', descriptor=algorithm)

    def is_valid(self):
        if self.validity == 0:
            return True  # This token is perpetually valid
        now = utcnow()
        if self.created_at < now - timedelta(seconds=self.validity):
            return False
        return True

    @classmethod
    def migrate_user(cls, old_user, new_user):
        if not old_user or not new_user:
            return  # Don't mess with client-only tokens
        oldtokens = cls.query.filter_by(user=old_user).all()
        newtokens = {}  # AuthClient: token mapping
        for token in cls.query.filter_by(user=new_user).all():
            newtokens.setdefault(token.auth_client_id, []).append(token)

        for token in oldtokens:
            merge_performed = False
            if token.auth_client_id in newtokens:
                for newtoken in newtokens[token.auth_client_id]:
                    if newtoken.user == new_user:
                        # There's another token for newuser with the same client.
                        # Just extend the scope there
                        newtoken.scope = set(newtoken.scope) | set(token.scope)
                        db.session.delete(token)
                        merge_performed = True
                        break
            if merge_performed is False:
                token.user = new_user  # Reassign this token to newuser

    @classmethod
    def get(cls, token):
        """
        Return an AuthToken with the matching token.

        :param str token: Token to lookup
        """
        query = cls.query.filter_by(token=token).options(
            db.joinedload(cls.auth_client).load_only('id', '_scope'))
        return query.one_or_none()

    @classmethod
    def get_for(cls, auth_client, user=None, user_session=None):
        require_one_of(user=user, user_session=user_session)
        if user:
            return cls.query.filter_by(auth_client=auth_client,
                                       user=user).one_or_none()
        else:
            return cls.query.filter_by(
                auth_client=auth_client,
                user_session=user_session).one_or_none()

    @classmethod  # NOQA: A003
    def all(cls, users):  # NOQA: A003
        """
        Return all AuthToken for the specified users.
        """
        query = cls.query.options(
            db.joinedload(cls.auth_client).load_only('id', '_scope'))
        if isinstance(users, QueryBaseClass):
            count = users.count()
            if count == 1:
                return query.filter_by(user=users.first()).all()
            elif count > 1:
                return query.filter(
                    AuthToken.user_id.in_(users.options(
                        load_only('id')))).all()
        else:
            count = len(users)
            if count == 1:
                # Cast users into a list/tuple before accessing [0], as the source
                # may not be an actual list with indexed access. For example,
                # Organization.owner_users is a DynamicAssociationProxy.
                return query.filter_by(user=tuple(users)[0]).all()
            elif count > 1:
                return query.filter(
                    AuthToken.user_id.in_([u.id for u in users])).all()

        return []
Ejemplo n.º 28
0
class Comment(UuidMixin, BaseMixin, db.Model):
    __tablename__ = 'comment'

    user_id = db.Column(None, db.ForeignKey('user.id'), nullable=True)
    _user = db.relationship(User,
                            backref=db.backref('comments',
                                               lazy='dynamic',
                                               cascade='all'))
    commentset_id = db.Column(None,
                              db.ForeignKey('commentset.id'),
                              nullable=False)
    commentset = with_roles(
        db.relationship(Commentset,
                        backref=db.backref('comments', cascade='all')),
        grants_via={None: {'document_subscriber'}},
    )

    in_reply_to_id = db.Column(None,
                               db.ForeignKey('comment.id'),
                               nullable=True)
    replies = db.relationship('Comment',
                              backref=db.backref('in_reply_to',
                                                 remote_side='Comment.id'))

    _message = MarkdownColumn('message', nullable=False)

    _state = db.Column(
        'state',
        db.Integer,
        StateManager.check_constraint('state', COMMENT_STATE),
        default=COMMENT_STATE.PUBLIC,
        nullable=False,
    )
    state = StateManager('_state',
                         COMMENT_STATE,
                         doc="Current state of the comment.")

    voteset_id = db.Column(None, db.ForeignKey('voteset.id'), nullable=False)
    voteset = db.relationship(Voteset, uselist=False)

    edited_at = with_roles(
        db.Column(db.TIMESTAMP(timezone=True), nullable=True),
        read={'all'},
        datasets={'primary', 'related', 'json'},
    )

    __roles__ = {
        'all': {
            'read': {'created_at', 'urls', 'uuid_b58'},
            'call': {'state', 'commentset', 'view_for', 'url_for'},
        },
        'replied_to_commenter': {
            'granted_via': {
                'in_reply_to': '_user'
            }
        },
    }

    __datasets__ = {
        'primary': {'created_at', 'urls', 'uuid_b58'},
        'related': {'created_at', 'urls', 'uuid_b58'},
        'json': {'created_at', 'urls', 'uuid_b58'},
    }

    search_vector = db.deferred(
        db.Column(
            TSVectorType(
                'message_text',
                weights={'message_text': 'A'},
                regconfig='english',
                hltext=lambda: Comment.message_html,
            ),
            nullable=False,
        ))

    __table_args__ = (db.Index('ix_comment_search_vector',
                               'search_vector',
                               postgresql_using='gin'), )

    def __init__(self, **kwargs):
        super(Comment, self).__init__(**kwargs)
        self.voteset = Voteset(settype=SET_TYPE.COMMENT)

    @with_roles(read={'all'}, datasets={'related', 'json'})
    @property
    def current_access_replies(self):
        return [
            reply.current_access(datasets=('json', 'related'))
            for reply in self.replies if reply.state.PUBLIC
        ]

    @hybrid_property
    def user(self):
        return (deleted_user if self.state.DELETED else
                removed_user if self.state.SPAM else self._user)

    @user.setter
    def user(self, value):
        self._user = value

    @user.expression
    def user(cls):  # NOQA: N805
        return cls._user

    with_roles(user, read={'all'}, datasets={'primary', 'related', 'json'})

    @hybrid_property
    def message(self):
        return (_('[deleted]') if self.state.DELETED else
                _('[removed]') if self.state.SPAM else self._message)

    @message.setter
    def message(self, value):
        self._message = value

    @message.expression
    def message(cls):  # NOQA: N805
        return cls._message

    with_roles(message, read={'all'}, datasets={'primary', 'related', 'json'})

    @with_roles(read={'all'}, datasets={'primary', 'related', 'json'})
    @property
    def absolute_url(self):
        return self.url_for()

    @with_roles(read={'all'}, datasets={'primary', 'related', 'json'})
    @property
    def title(self):
        obj = self.commentset.parent
        if obj:
            return _("{user} commented on {obj}").format(
                user=self.user.pickername, obj=obj.title)
        else:
            return _("{user} commented").format(user=self.user.pickername)

    @with_roles(read={'all'}, datasets={'related', 'json'})
    @property
    def badges(self):
        badges = set()
        if self.commentset.project is not None:
            if 'crew' in self.commentset.project.roles_for(self._user):
                badges.add(_("Crew"))
        elif self.commentset.proposal is not None:
            if self.commentset.proposal.user == self._user:
                badges.add(_("Proposer"))
            if 'crew' in self.commentset.proposal.project.roles_for(
                    self._user):
                badges.add(_("Crew"))
        return badges

    @state.transition(None, state.DELETED)
    def delete(self):
        """
        Delete this comment.
        """
        if len(self.replies) > 0:
            self.user = None
            self.message = ''
        else:
            if self.in_reply_to and self.in_reply_to.state.DELETED:
                # If the comment this is replying to is deleted, ask it to reconsider
                # removing itself
                in_reply_to = self.in_reply_to
                in_reply_to.replies.remove(self)
                db.session.delete(self)
                in_reply_to.delete()
            else:
                db.session.delete(self)

    @state.transition(None, state.SPAM)
    def mark_spam(self):
        """
        Mark this comment as spam.
        """

    @state.transition(state.SPAM, state.PUBLIC)
    def mark_not_spam(self):
        """
        Mark this comment as not a spam.
        """

    def sorted_replies(self):
        return sorted(self.replies, key=lambda comment: comment.voteset.count)

    def permissions(self, user, inherited=None):
        perms = super(Comment, self).permissions(user, inherited)
        perms.add('view')
        if user is not None:
            perms.add('vote_comment')
            if user == self._user:
                perms.add('edit_comment')
                perms.add('delete_comment')
        return perms

    def roles_for(self, actor=None, anchors=()):
        roles = super(Comment, self).roles_for(actor, anchors)
        roles.add('reader')
        if actor is not None:
            if actor == self._user:
                roles.add('author')
        return roles
Ejemplo n.º 29
0
class AuthClient(ScopeMixin, UuidMixin, BaseMixin, db.Model):
    """OAuth client applications"""

    __tablename__ = 'auth_client'
    __scope_null_allowed__ = True
    #: User who owns this client
    user_id = db.Column(None, db.ForeignKey('user.id'), nullable=True)
    user = with_roles(
        db.relationship(
            User,
            primaryjoin=user_id == User.id,
            backref=db.backref('clients', cascade='all'),
        ),
        read={'all'},
        write={'owner'},
        grants={'owner'},
    )
    #: Organization that owns this client. Only one of this or user must be set
    organization_id = db.Column(None,
                                db.ForeignKey('organization.id'),
                                nullable=True)
    organization = with_roles(
        db.relationship(
            Organization,
            primaryjoin=organization_id == Organization.id,
            backref=db.backref('clients', cascade='all'),
        ),
        read={'all'},
        write={'owner'},
        grants_via={None: {
            'owner': 'owner',
            'admin': 'owner'
        }},
    )
    #: Human-readable title
    title = with_roles(db.Column(db.Unicode(250), nullable=False),
                       read={'all'},
                       write={'owner'})
    #: Long description
    description = with_roles(
        db.Column(db.UnicodeText, nullable=False, default=''),
        read={'all'},
        write={'owner'},
    )
    #: Confidential or public client? Public has no secret key
    confidential = with_roles(db.Column(db.Boolean, nullable=False),
                              read={'all'},
                              write={'owner'})
    #: Website
    website = with_roles(db.Column(db.UnicodeText, nullable=False),
                         read={'all'},
                         write={'owner'})
    # TODO: Remove namespace as resources are deprecated
    #: Namespace: determines inter-app resource access
    namespace = with_roles(
        db.Column(db.UnicodeText, nullable=True, unique=True),
        read={'all'},
        write={'owner'},
    )
    #: Redirect URIs (one or more)
    _redirect_uris = db.Column('redirect_uri',
                               db.UnicodeText,
                               nullable=True,
                               default='')
    #: Back-end notification URI
    notification_uri = with_roles(db.Column(db.UnicodeText,
                                            nullable=True,
                                            default=''),
                                  rw={'owner'})
    #: Active flag
    active = db.Column(db.Boolean, nullable=False, default=True)
    #: Allow anyone to login to this app?
    allow_any_login = with_roles(
        db.Column(db.Boolean, nullable=False, default=True),
        read={'all'},
        write={'owner'},
    )
    #: Trusted flag: trusted clients are authorized to access user data
    #: without user consent, but the user must still login and identify themself.
    #: When a single provider provides multiple services, each can be declared
    #: as a trusted client to provide single sign-in across the services.
    #: However, resources in the scope column (via ScopeMixin) are granted for
    #: any arbitrary user without explicit user authorization.
    trusted = with_roles(db.Column(db.Boolean, nullable=False, default=False),
                         read={'all'})

    user_sessions = db.relationship(
        UserSession,
        lazy='dynamic',
        secondary=auth_client_user_session,
        backref=db.backref('auth_clients', lazy='dynamic'),
    )

    __table_args__ = (db.CheckConstraint(
        db.case([(user_id.isnot(None), 1)], else_=0) +
        db.case([(organization_id.isnot(None), 1)], else_=0) == 1,
        name='auth_client_owner_check',
    ), )
Ejemplo n.º 30
0
class Project(UuidMixin, BaseScopedNameMixin, db.Model):
    __tablename__ = 'project'
    reserved_names = RESERVED_NAMES

    user_id = db.Column(None, db.ForeignKey('user.id'), nullable=False)
    user = db.relationship(
        User,
        primaryjoin=user_id == User.id,
        backref=db.backref('projects', cascade='all'),
    )
    profile_id = db.Column(None, db.ForeignKey('profile.id'), nullable=False)
    profile = with_roles(
        db.relationship('Profile',
                        backref=db.backref('projects',
                                           cascade='all',
                                           lazy='dynamic')),
        read={'all'},
        # If profile grants an 'admin' role, make it 'profile_admin' here
        grants_via={None: {
            'admin': 'profile_admin'
        }},
        # `profile` only appears in the 'primary' dataset. It must not be included in
        # 'related' or 'without_parent' as it is the parent
        datasets={'primary'},
    )
    parent = db.synonym('profile')
    tagline = with_roles(
        db.Column(db.Unicode(250), nullable=False),
        read={'all'},
        datasets={'primary', 'without_parent', 'related'},
    )
    description = with_roles(MarkdownColumn('description',
                                            default='',
                                            nullable=False),
                             read={'all'})
    instructions = with_roles(MarkdownColumn('instructions',
                                             default='',
                                             nullable=True),
                              read={'all'})

    location = with_roles(
        db.Column(db.Unicode(50), default='', nullable=True),
        read={'all'},
        datasets={'primary', 'without_parent', 'related'},
    )
    parsed_location = db.Column(JsonDict, nullable=False, server_default='{}')

    website = with_roles(
        db.Column(UrlType, nullable=True),
        read={'all'},
        datasets={'primary', 'without_parent'},
    )
    timezone = with_roles(
        db.Column(TimezoneType(backend='pytz'), nullable=False, default=utc),
        read={'all'},
        datasets={'primary', 'without_parent', 'related'},
    )

    _state = db.Column(
        'state',
        db.Integer,
        StateManager.check_constraint('state', PROJECT_STATE),
        default=PROJECT_STATE.DRAFT,
        nullable=False,
    )
    state = with_roles(StateManager('_state',
                                    PROJECT_STATE,
                                    doc="Project state"),
                       call={'all'})
    _cfp_state = db.Column(
        'cfp_state',
        db.Integer,
        StateManager.check_constraint('cfp_state', CFP_STATE),
        default=CFP_STATE.NONE,
        nullable=False,
    )
    cfp_state = with_roles(StateManager('_cfp_state',
                                        CFP_STATE,
                                        doc="CfP state"),
                           call={'all'})
    _schedule_state = db.Column(
        'schedule_state',
        db.Integer,
        StateManager.check_constraint('schedule_state', SCHEDULE_STATE),
        default=SCHEDULE_STATE.DRAFT,
        nullable=False,
    )
    schedule_state = with_roles(
        StateManager('_schedule_state', SCHEDULE_STATE, doc="Schedule state"),
        call={'all'},
    )

    cfp_start_at = db.Column(db.TIMESTAMP(timezone=True), nullable=True)
    cfp_end_at = db.Column(db.TIMESTAMP(timezone=True), nullable=True)

    bg_image = with_roles(
        db.Column(UrlType, nullable=True),
        read={'all'},
        datasets={'primary', 'without_parent'},
    )
    allow_rsvp = db.Column(db.Boolean, default=False, nullable=False)
    buy_tickets_url = db.Column(UrlType, nullable=True)

    banner_video_url = with_roles(
        db.Column(UrlType, nullable=True),
        read={'all'},
        datasets={'primary', 'without_parent'},
    )
    boxoffice_data = with_roles(
        db.Column(JsonDict, nullable=False, server_default='{}'),
        # This is an attribute, but we deliberately use `call` instead of `read` to
        # block this from dictionary enumeration. FIXME: Break up this dictionary into
        # individual columns with `all` access for ticket embed id and `concierge`
        # access for ticket sync access token.
        call={'all'},
    )

    hasjob_embed_url = with_roles(db.Column(UrlType, nullable=True),
                                  read={'all'})
    hasjob_embed_limit = with_roles(db.Column(db.Integer, default=8),
                                    read={'all'})

    voteset_id = db.Column(None, db.ForeignKey('voteset.id'), nullable=False)
    voteset = db.relationship(Voteset, uselist=False)

    commentset_id = db.Column(None,
                              db.ForeignKey('commentset.id'),
                              nullable=False)
    commentset = db.relationship(
        Commentset,
        uselist=False,
        cascade='all',
        single_parent=True,
        back_populates='project',
    )

    parent_id = db.Column(None,
                          db.ForeignKey('project.id', ondelete='SET NULL'),
                          nullable=True)
    parent_project = db.relationship('Project',
                                     remote_side='Project.id',
                                     backref='subprojects')

    #: Featured project flag. This can only be set by website editors, not
    #: project editors or profile admins.
    featured = with_roles(
        db.Column(db.Boolean, default=False, nullable=False),
        read={'all'},
        write={'site_editor'},
        datasets={'primary', 'without_parent'},
    )

    search_vector = db.deferred(
        db.Column(
            TSVectorType(
                'name',
                'title',
                'description_text',
                'instructions_text',
                'location',
                weights={
                    'name': 'A',
                    'title': 'A',
                    'description_text': 'B',
                    'instructions_text': 'B',
                    'location': 'C',
                },
                regconfig='english',
                hltext=lambda: db.func.concat_ws(
                    visual_field_delimiter,
                    Project.title,
                    Project.location,
                    Project.description_html,
                    Project.instructions_html,
                ),
            ),
            nullable=False,
        ))

    livestream_urls = with_roles(
        db.Column(db.ARRAY(db.UnicodeText, dimensions=1), server_default='{}'),
        read={'all'},
        datasets={'primary', 'without_parent'},
    )

    venues = with_roles(
        db.relationship(
            'Venue',
            cascade='all',
            order_by='Venue.seq',
            collection_class=ordering_list('seq', count_from=1),
        ),
        read={'all'},
    )
    labels = db.relationship(
        'Label',
        cascade='all',
        primaryjoin=
        'and_(Label.project_id == Project.id, Label.main_label_id == None, Label._archived == False)',
        order_by='Label.seq',
        collection_class=ordering_list('seq', count_from=1),
    )
    all_labels = db.relationship('Label', lazy='dynamic')

    __table_args__ = (
        db.UniqueConstraint('profile_id', 'name'),
        db.Index('ix_project_search_vector',
                 'search_vector',
                 postgresql_using='gin'),
    )

    __roles__ = {
        'all': {
            'read': {
                'absolute_url',  # From UrlForMixin
                'name',  # From BaseScopedNameMixin
                'short_title',  # From BaseScopedNameMixin
                'title',  # From BaseScopedNameMixin
                'urls',  # From UrlForMixin
            },
            'call': {
                'features',  # From RegistryMixin
                'forms',  # From RegistryMixin
                'url_for',  # From UrlForMixin
                'view_for',  # From UrlForMixin
                'views',  # From RegistryMixin
            },
        },
    }

    __datasets__ = {
        'primary': {
            'absolute_url',  # From UrlForMixin
            'name',  # From BaseScopedNameMixin
            'title',  # From BaseScopedNameMixin
            'urls',  # From UrlForMixin
        },
        'without_parent': {
            'absolute_url',  # From UrlForMixin
            'name',  # From BaseScopedNameMixin
            'title',  # From BaseScopedNameMixin
        },
        'related': {
            'absolute_url',  # From UrlForMixin
            'name',  # From BaseScopedNameMixin
            'title',  # From BaseScopedNameMixin
        },
    }

    schedule_state.add_conditional_state(
        'PAST',
        schedule_state.PUBLISHED,
        lambda project: project.schedule_end_at is not None and utcnow() >=
        project.schedule_end_at,
        lambda project: db.func.utcnow() >= project.schedule_end_at,
        label=('past', __("Past")),
    )
    schedule_state.add_conditional_state(
        'LIVE',
        schedule_state.PUBLISHED,
        lambda project:
        (project.schedule_start_at is not None and project.schedule_start_at <=
         utcnow() < project.schedule_end_at),
        lambda project: db.and_(
            project.schedule_start_at <= db.func.utcnow(),
            db.func.utcnow() < project.schedule_end_at,
        ),
        label=('live', __("Live")),
    )
    schedule_state.add_conditional_state(
        'UPCOMING',
        schedule_state.PUBLISHED,
        lambda project: project.schedule_start_at is not None and utcnow() <
        project.schedule_start_at,
        lambda project: db.func.utcnow() < project.schedule_start_at,
        label=('upcoming', __("Upcoming")),
    )
    schedule_state.add_conditional_state(
        'PUBLISHED_WITHOUT_SESSIONS',
        schedule_state.PUBLISHED,
        lambda project: project.schedule_start_at is None,
        lambda project: project.schedule_start_at.is_(None),
        label=('published_without_sessions', __("Published without sessions")),
    )

    cfp_state.add_conditional_state(
        'HAS_PROPOSALS',
        cfp_state.EXISTS,
        lambda project: db.session.query(project.proposals.exists()).scalar(),
        label=('has_proposals', __("Has proposals")),
    )
    cfp_state.add_conditional_state(
        'HAS_SESSIONS',
        cfp_state.EXISTS,
        lambda project: db.session.query(project.sessions.exists()).scalar(),
        label=('has_sessions', __("Has sessions")),
    )
    cfp_state.add_conditional_state(
        'PRIVATE_DRAFT',
        cfp_state.NONE,
        lambda project: project.instructions_html != '',
        lambda project: db.and_(project.instructions_html.isnot(None), project.
                                instructions_html != ''),
        label=('private_draft', __("Private draft")),
    )
    cfp_state.add_conditional_state(
        'DRAFT',
        cfp_state.PUBLIC,
        lambda project: project.cfp_start_at is None,
        lambda project: project.cfp_start_at.is_(None),
        label=('draft', __("Draft")),
    )
    cfp_state.add_conditional_state(
        'UPCOMING',
        cfp_state.PUBLIC,
        lambda project: project.cfp_start_at is not None and utcnow() < project
        .cfp_start_at,
        lambda project: db.and_(project.cfp_start_at.isnot(None),
                                db.func.utcnow() < project.cfp_start_at),
        label=('upcoming', __("Upcoming")),
    )
    cfp_state.add_conditional_state(
        'OPEN',
        cfp_state.PUBLIC,
        lambda project: project.cfp_start_at is not None and project.
        cfp_start_at <= utcnow() and (project.cfp_end_at is None or
                                      (utcnow() < project.cfp_end_at)),
        lambda project: db.and_(
            project.cfp_start_at.isnot(None),
            project.cfp_start_at <= db.func.utcnow(),
            db.or_(project.cfp_end_at.is_(None),
                   db.func.utcnow() < project.cfp_end_at),
        ),
        label=('open', __("Open")),
    )
    cfp_state.add_conditional_state(
        'EXPIRED',
        cfp_state.PUBLIC,
        lambda project: project.cfp_end_at is not None and utcnow() >= project.
        cfp_end_at,
        lambda project: db.and_(project.cfp_end_at.isnot(None),
                                db.func.utcnow() >= project.cfp_end_at),
        label=('expired', __("Expired")),
    )

    cfp_state.add_state_group('UNAVAILABLE', cfp_state.CLOSED,
                              cfp_state.EXPIRED)

    def __init__(self, **kwargs):
        super(Project, self).__init__(**kwargs)
        self.voteset = Voteset(settype=SET_TYPE.PROJECT)
        self.commentset = Commentset(settype=SET_TYPE.PROJECT)
        # Add the creator as editor and concierge
        new_membership = ProjectCrewMembership(
            parent=self,
            user=self.user,
            granted_by=self.user,
            is_editor=True,
            is_concierge=True,
        )
        db.session.add(new_membership)

    def __repr__(self):
        return '<Project %s/%s "%s">' % (
            self.profile.name if self.profile else '(none)',
            self.name,
            self.title,
        )

    @with_roles(call={'editor'})
    @cfp_state.transition(
        cfp_state.OPENABLE,
        cfp_state.PUBLIC,
        title=__("Enable proposal submissions"),
        message=__("Proposals can be now submitted"),
        type='success',
    )
    def open_cfp(self):
        pass

    @with_roles(call={'editor'})
    @cfp_state.transition(
        cfp_state.PUBLIC,
        cfp_state.CLOSED,
        title=__("Disable proposal submissions"),
        message=__("Proposals will no longer be accepted"),
        type='success',
    )
    def close_cfp(self):
        pass

    @with_roles(call={'editor'})
    @schedule_state.transition(
        schedule_state.DRAFT,
        schedule_state.PUBLISHED,
        title=__("Publish schedule"),
        message=__("The schedule has been published"),
        type='success',
    )
    def publish_schedule(self):
        pass

    @with_roles(call={'editor'})
    @schedule_state.transition(
        schedule_state.PUBLISHED,
        schedule_state.DRAFT,
        title=__("Unpublish schedule"),
        message=__("The schedule has been moved to draft state"),
        type='success',
    )
    def unpublish_schedule(self):
        pass

    @with_roles(call={'editor'})
    @state.transition(
        state.PUBLISHABLE,
        state.PUBLISHED,
        title=__("Publish project"),
        message=__("The project has been published"),
        type='success',
    )
    def publish(self):
        pass

    @with_roles(call={'editor'})
    @state.transition(
        state.PUBLISHED,
        state.WITHDRAWN,
        title=__("Withdraw project"),
        message=__("The project has been withdrawn and is no longer listed"),
        type='success',
    )
    def withdraw(self):
        pass

    @with_roles(read={'all'}, datasets={'primary', 'without_parent'})
    @property
    def title_inline(self):
        """Suffix a colon if the title does not end in ASCII sentence punctuation"""
        if self.title and self.tagline:
            if not self.title[-1] in ('?', '!', ':', ';', '.', ','):
                return self.title + ':'
        return self.title

    @with_roles(read={'all'})
    @property
    def title_suffix(self):
        """
        Return the profile's title if the project's title doesn't derive from it.

        Used in HTML title tags to render <title>{{ project }} - {{ suffix }}</title>.
        """
        if not self.title.startswith(self.parent.title):
            return self.profile.title
        return ''

    @with_roles(call={'all'})
    def joined_title(self, sep='›'):
        """Return the project's title joined with the profile's title, if divergent."""
        if self.short_title == self.title:
            # Project title does not derive from profile title, so use both
            return f"{self.profile.title} {sep} {self.title}"
        # Project title extends profile title, so profile title is not needed
        return self.title

    @with_roles(read={'all'},
                datasets={'primary', 'without_parent', 'related'})
    @cached_property
    def datelocation(self):
        """
        Returns a date + location string for the event, the format depends on project dates

        If it's a single day event
        > 11 Feb 2018, Bangalore

        If multi-day event in same month
        > 09–12 Feb 2018, Bangalore

        If multi-day event across months
        > 27 Feb–02 Mar 2018, Bangalore

        If multi-day event across years
        > 30 Dec 2018–02 Jan 2019, Bangalore

        ``datelocation_format`` always keeps ``schedule_end_at`` format as ``–DD Mmm YYYY``.
        Depending on the scenario mentioned below, format for ``schedule_start_at`` changes. Above examples
        demonstrate the same. All the possible outputs end with ``–DD Mmm YYYY, Venue``.
        Only ``schedule_start_at`` format changes.
        """
        daterange = ''
        if self.schedule_start_at is not None and self.schedule_end_at is not None:
            schedule_start_at_date = self.schedule_start_at_localized.date()
            schedule_end_at_date = self.schedule_end_at_localized.date()
            daterange_format = '{start_date}–{end_date} {year}'
            if schedule_start_at_date == schedule_end_at_date:
                # if both dates are same, in case of single day project
                strf_date = ''
                daterange_format = '{end_date} {year}'
            elif schedule_start_at_date.year != schedule_end_at_date.year:
                # if the start date and end dates are in different years,
                strf_date = '%d %b %Y'
            elif schedule_start_at_date.month != schedule_end_at_date.month:
                # If multi-day event across months
                strf_date = '%d %b'
            elif schedule_start_at_date.month == schedule_end_at_date.month:
                # If multi-day event in same month
                strf_date = '%d'
            daterange = daterange_format.format(
                start_date=schedule_start_at_date.strftime(strf_date),
                end_date=schedule_end_at_date.strftime('%d %b'),
                year=schedule_end_at_date.year,
            )
        return ', '.join([_f for _f in [daterange, self.location] if _f])

    # TODO: Removing Delete feature till we figure out siteadmin feature
    # @with_roles(call={'editor'})
    # @state.transition(
    #     state.DELETABLE, state.DELETED, title=__("Delete project"),
    #     message=__("The project has been deleted"), type='success')
    # def delete(self):
    #     pass

    @db.validates('name', 'profile')
    def _validate_and_create_redirect(self, key, value):
        # TODO: When labels, venues and other resources are relocated from project to
        # profile, this validator can no longer watch profile change. We'll need a more
        # elaborate transfer mechanism that remaps resources to equivalent ones in the
        # new profile.
        if key == 'name':
            value = value.strip() if value is not None else None
        if not value or (key == 'name' and not valid_name(value)):
            raise ValueError(f"Invalid value for {key}: {value!r}")
        existing_value = getattr(self, key)
        if value != existing_value and existing_value is not None:
            ProjectRedirect.add(self)
        return value

    @with_roles(read={'all'}, datasets={'primary', 'without_parent'})
    @cached_property
    def cfp_start_at_localized(self):
        return (localize_timezone(self.cfp_start_at, tz=self.timezone)
                if self.cfp_start_at else None)

    @with_roles(read={'all'}, datasets={'primary', 'without_parent'})
    @cached_property
    def cfp_end_at_localized(self):
        return (localize_timezone(self.cfp_end_at, tz=self.timezone)
                if self.cfp_end_at else None)

    @cached_property
    def location_geonameid(self):
        return geonameid_from_location(
            self.location) if self.location else set()

    def permissions(self, user, inherited=None):
        # TODO: Remove permission system entirely
        perms = super(Project, self).permissions(user, inherited)
        perms.add('view')
        if user is not None:
            if self.cfp_state.OPEN:
                perms.add('new-proposal')
            if 'editor' in self.roles_for(user):
                perms.update((
                    'view_contactinfo',
                    'edit_project',
                    'delete-project',
                    'confirm-proposal',
                    'view-venue',
                    'new-venue',
                    'edit-venue',
                    'delete-venue',
                    'edit-schedule',
                    'move-proposal',
                    'view_rsvps',
                    'new-session',
                    'edit-session',
                    'new-event',
                    'new-ticket-type',
                    'new_ticket_client',
                    'edit_ticket_client',
                    'delete_ticket_client',
                    'edit_event',
                    'delete_event',
                    'admin',
                    'checkin_event',
                    'view-event',
                    'view_ticket_type',
                    'delete_ticket_type',
                    'edit-participant',
                    'view-participant',
                    'new-participant',
                    'view_contactinfo',
                    'confirm-proposal',
                    'view_voteinfo',
                    'view_status',
                    'delete-proposal',
                    'edit-schedule',
                    'new-session',
                    'edit-session',
                    'view-event',
                    'view_ticket_type',
                    'edit-participant',
                    'view-participant',
                    'new-participant',
                ))
            if 'usher' in self.roles_for(user):
                perms.add('checkin_event')
        return perms

    def roles_for(self, actor=None, anchors=()):
        roles = super().roles_for(actor, anchors)
        # https://github.com/hasgeek/funnel/pull/220#discussion_r168718052
        roles.add('reader')
        return roles

    @classmethod
    def all_unsorted(cls, legacy=None):
        """
        Return currently active events, not sorted.
        """
        projects = cls.query.outerjoin(Venue).filter(cls.state.PUBLISHED)
        if legacy is not None:
            projects = projects.join(Profile).filter(Profile.legacy == legacy)
        return projects

    @classmethod  # NOQA: A003
    def all(cls, legacy=None):  # NOQA: A003
        """
        Return currently active events, sorted by date.
        """
        return cls.all_unsorted(legacy).order_by(cls.schedule_start_at.desc())

    @classmethod
    def fetch_sorted(cls, legacy=None):
        currently_listed_projects = cls.query.filter_by(
            parent_project=None).filter(cls.state.PUBLISHED)
        if legacy is not None:
            currently_listed_projects = currently_listed_projects.join(
                Profile).filter(Profile.legacy == legacy)
        currently_listed_projects = currently_listed_projects.order_by(
            cls.schedule_start_at.desc())
        return currently_listed_projects

    @classmethod
    def get(cls, profile_project):
        """Get a project by its URL slug in the form ``<profile>/<project>``."""
        profile_name, project_name = profile_project.split('/')
        return (cls.query.join(Profile).filter(
            Profile.name == profile_name,
            Project.name == project_name).one_or_none())

    @classmethod
    def migrate_profile(cls, old_profile, new_profile):
        names = {project.name for project in new_profile.projects}
        for project in old_profile.projects:
            if project.name in names:
                current_app.logger.warning(
                    "Project %r had a conflicting name in profile migration, "
                    "so renaming by adding adding random value to name",
                    project,
                )
                project.name += '-' + buid()
            project.profile = new_profile
class OrganizationMembership(ImmutableMembershipMixin, db.Model):
    """
    A user can be an administrator of an organization and optionally an owner.
    Owners can manage other administrators. This model may introduce non-admin
    memberships in a future iteration by replacing :attr:`is_owner` with
    :attr:`member_level` or distinct role flags as in :class:`ProjectMembership`.
    """

    __tablename__ = 'organization_membership'

    # List of role columns in this model
    __data_columns__ = ('is_owner', )

    __roles__ = {
        'all': {
            'read': {'urls', 'user', 'is_owner', 'organization'}
        },
        'profile_admin': {
            'read': {
                'record_type',
                'granted_at',
                'granted_by',
                'revoked_at',
                'revoked_by',
                'user',
                'is_active',
                'is_invite',
            }
        },
    }
    __datasets__ = {
        'primary': {
            'urls',
            'uuid_b58',
            'offered_roles',
            'is_owner',
            'user',
            'organization',
        },
        'without_parent':
        {'urls', 'uuid_b58', 'offered_roles', 'is_owner', 'user'},
        'related': {'urls', 'uuid_b58', 'offered_roles', 'is_owner'},
    }

    #: Organization that this membership is being granted on
    organization_id = immutable(
        db.Column(None,
                  db.ForeignKey('organization.id', ondelete='CASCADE'),
                  nullable=False))
    organization = immutable(
        with_roles(
            db.relationship(
                Organization,
                backref=db.backref('memberships',
                                   lazy='dynamic',
                                   cascade='all',
                                   passive_deletes=True),
            ),
            grants_via={
                None: {
                    'admin': 'profile_admin',
                    'owner': 'profile_owner'
                }
            },
        ))
    parent = immutable(db.synonym('organization'))
    parent_id = immutable(db.synonym('organization_id'))

    # Organization roles:
    is_owner = immutable(db.Column(db.Boolean, nullable=False, default=False))

    @cached_property
    def offered_roles(self):
        """Roles offered by this membership record"""
        roles = {'admin'}
        if self.is_owner:
            roles.add('owner')
        return roles