Example #1
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)
Example #2
0
class JobApplication(BaseMixin, db.Model):
    __tablename__ = 'job_application'
    #: Hash id (to hide database ids)
    hashid = db.Column(db.String(40), nullable=False, unique=True)
    #: User who applied for this post
    user_id = db.Column(None,
                        db.ForeignKey('user.id'),
                        nullable=True,
                        index=True)  # TODO: add unique=True
    user = db.relationship(User, foreign_keys=user_id)
    #: Full name of the user (as it was at the time of the application)
    fullname = db.Column(db.Unicode(250), nullable=False)
    #: Job post they applied to
    jobpost_id = db.Column(None,
                           db.ForeignKey('jobpost.id'),
                           nullable=False,
                           index=True)
    # jobpost relationship is below, outside the class definition
    #: User's email address
    email = db.Column(db.Unicode(80), nullable=False)
    #: User's phone number
    phone = db.Column(db.Unicode(80), nullable=False)
    #: User's message
    message = db.Column(db.UnicodeText, nullable=False)
    #: User opted-in to experimental features
    optin = db.Column(db.Boolean, default=False, nullable=False)
    #: Employer's response code
    _response = db.Column('response',
                          db.Integer,
                          StateManager.check_constraint(
                              'response', EMPLOYER_RESPONSE),
                          nullable=False,
                          default=EMPLOYER_RESPONSE.NEW)
    response = StateManager('_response',
                            EMPLOYER_RESPONSE,
                            doc="Employer's response")
    #: Employer's response message
    response_message = db.Column(db.UnicodeText, nullable=True)
    #: Bag of words, for spam analysis
    words = db.Column(db.UnicodeText, nullable=True)
    #: Jobpost admin who replied to this candidate
    replied_by_id = db.Column(None,
                              db.ForeignKey('user.id'),
                              nullable=True,
                              index=True)
    replied_by = db.relationship(User, foreign_keys=replied_by_id)
    #: When they replied
    replied_at = db.Column(db.DateTime, nullable=True)

    candidate_feedback = db.Column(db.SmallInteger, nullable=True)

    def __init__(self, **kwargs):
        super(JobApplication, self).__init__(**kwargs)
        if self.hashid is None:
            self.hashid = unique_long_hash()

    @response.transition(response.NEW,
                         response.PENDING,
                         title=__("Mark read"),
                         message=__("This job application has been read"),
                         type='success')
    def mark_read(self):
        pass

    @response.transition(
        response.CAN_REPLY,
        response.REPLIED,
        title=__("Reply"),
        message=__("This job application has been replied to"),
        type='success')
    def reply(self, message, user):
        self.response_message = message
        self.replied_by = user
        self.replied_at = db.func.utcnow()

    @response.transition(response.CAN_REJECT,
                         response.REJECTED,
                         title=__("Reject"),
                         message=__("This job application has been rejected"),
                         type='danger')
    def reject(self, message, user):
        self.response_message = message
        self.replied_by = user
        self.replied_at = db.func.utcnow()

    @response.transition(response.CAN_IGNORE,
                         response.IGNORED,
                         title=__("Ignore"),
                         message=__("This job application has been ignored"),
                         type='danger')
    def ignore(self):
        pass

    @response.transition(response.CAN_REPORT,
                         response.FLAGGED,
                         title=__("Report"),
                         message=__("This job application has been reported"),
                         type='danger')
    def flag(self):
        pass

    @response.transition(response.FLAGGED,
                         response.PENDING,
                         title=__("Unflag"),
                         message=__("This job application has been unflagged"),
                         type='success')
    def unflag(self):
        pass

    def application_count(self):
        """Number of jobs candidate has applied to around this one"""
        if not self.user:
            # Kiosk submission, no data available
            return {
                'count': 0,
                'ignored': 0,
                'replied': 0,
                'flagged': 0,
                'spam': 0,
                'rejected': 0
            }
        date_min = self.created_at - timedelta(days=7)
        date_max = self.created_at + timedelta(days=7)
        grouped = JobApplication.response.group(
            JobApplication.query.filter(
                JobApplication.user == self.user).filter(
                    JobApplication.created_at > date_min,
                    JobApplication.created_at < date_max).options(
                        db.load_only('id')))
        counts = {k.label.name: len(v) for k, v in grouped.items()}
        counts['count'] = sum(counts.values())
        return counts

    def url_for(self, action='view', _external=False, **kwargs):
        domain = self.jobpost.email_domain
        if action == 'view':
            return url_for('view_application',
                           hashid=self.jobpost.hashid,
                           domain=domain,
                           application=self.hashid,
                           _external=_external,
                           **kwargs)
        elif action == 'process':
            return url_for('process_application',
                           hashid=self.jobpost.hashid,
                           domain=domain,
                           application=self.hashid,
                           _external=_external,
                           **kwargs)
        elif action == 'track-open':
            return url_for('view_application_email_gif',
                           hashid=self.jobpost.hashid,
                           domain=domain,
                           application=self.hashid,
                           _external=_external,
                           **kwargs)
Example #3
0
class JobPost(BaseMixin, db.Model):
    __tablename__ = 'jobpost'

    # Metadata
    user_id = db.Column(None,
                        db.ForeignKey('user.id'),
                        nullable=True,
                        index=True)
    user = db.relationship(User,
                           primaryjoin=user_id == User.id,
                           backref=db.backref('jobposts', lazy='dynamic'))

    hashid = db.Column(db.String(5), nullable=False, unique=True)
    datetime = db.Column(db.DateTime,
                         default=db.func.utcnow(),
                         nullable=False,
                         index=True)  # Published
    closed_datetime = db.Column(db.DateTime,
                                nullable=True)  # If withdrawn or rejected
    # Pinned on the home page. Boards use the BoardJobPost.pinned column
    sticky = db.Column(db.Boolean, nullable=False, default=False)
    pinned = db.synonym('sticky')

    # Job description
    headline = db.Column(db.Unicode(100), nullable=False)
    headlineb = db.Column(db.Unicode(100), nullable=True)
    type_id = db.Column(None, db.ForeignKey('jobtype.id'), nullable=False)
    type = db.relation(JobType, primaryjoin=type_id == JobType.id)
    category_id = db.Column(None,
                            db.ForeignKey('jobcategory.id'),
                            nullable=False)
    category = db.relation(JobCategory,
                           primaryjoin=category_id == JobCategory.id)
    location = db.Column(db.Unicode(80), nullable=False)
    parsed_location = db.Column(JsonDict)
    # remote_location tracks whether the job is work-from-home/work-from-anywhere
    remote_location = db.Column(db.Boolean, default=False, nullable=False)
    relocation_assist = db.Column(db.Boolean, default=False, nullable=False)
    description = db.Column(db.UnicodeText, nullable=False)
    perks = db.Column(db.UnicodeText, nullable=False)
    how_to_apply = db.Column(db.UnicodeText, nullable=False)
    hr_contact = db.Column(db.Boolean, nullable=True)

    # Compensation details
    pay_type = db.Column(db.SmallInteger,
                         nullable=True)  # Value in models.PAY_TYPE
    pay_currency = db.Column(db.CHAR(3), nullable=True)
    pay_cash_min = db.Column(db.Integer, nullable=True)
    pay_cash_max = db.Column(db.Integer, nullable=True)
    pay_equity_min = db.Column(db.Numeric, nullable=True)
    pay_equity_max = db.Column(db.Numeric, nullable=True)

    # Company details
    company_name = db.Column(db.Unicode(80), nullable=False)
    company_logo = db.Column(db.Unicode(255), nullable=True)
    company_url = db.Column(db.Unicode(255), nullable=False, default=u'')
    twitter = db.Column(db.Unicode(15), nullable=True)
    fullname = db.Column(
        db.Unicode(80),
        nullable=True)  # Deprecated field, used before user_id was introduced
    email = db.Column(db.Unicode(80), nullable=False)
    email_domain = db.Column(db.Unicode(80), nullable=False, index=True)
    domain_id = db.Column(None, db.ForeignKey('domain.id'), nullable=False)
    md5sum = db.Column(db.String(32), nullable=False, index=True)

    # Payment, audit and workflow fields
    words = db.Column(
        db.UnicodeText,
        nullable=True)  # All words in description, perks and how_to_apply
    promocode = db.Column(db.String(40), nullable=True)
    ipaddr = db.Column(db.String(45), nullable=False)
    useragent = db.Column(db.Unicode(250), nullable=True)
    edit_key = db.Column(db.String(40),
                         nullable=False,
                         default=random_long_key)
    email_verify_key = db.Column(db.String(40),
                                 nullable=False,
                                 default=random_long_key)
    email_sent = db.Column(db.Boolean, nullable=False, default=False)
    email_verified = db.Column(db.Boolean, nullable=False, default=False)
    payment_value = db.Column(db.Integer, nullable=False, default=0)
    payment_received = db.Column(db.Boolean, nullable=False, default=False)
    reviewer_id = db.Column(None,
                            db.ForeignKey('user.id'),
                            nullable=True,
                            index=True)
    reviewer = db.relationship(User,
                               primaryjoin=reviewer_id == User.id,
                               backref="reviewed_posts")
    review_datetime = db.Column(db.DateTime, nullable=True)
    review_comments = db.Column(db.Unicode(250), nullable=True)

    search_vector = deferred(db.Column(TSVECTOR, nullable=True))

    _state = db.Column('status',
                       db.Integer,
                       StateManager.check_constraint('status', POST_STATE),
                       default=POST_STATE.DRAFT,
                       nullable=False)
    state = StateManager('_state',
                         POST_STATE,
                         doc="Current state of the job post")

    # Metadata for classification
    language = db.Column(db.CHAR(2), nullable=True)
    language_confidence = db.Column(db.Float, nullable=True)

    admins = db.relationship(User,
                             lazy='dynamic',
                             secondary=lambda: jobpost_admin_table,
                             backref=db.backref('admin_of', lazy='dynamic'))
    starred_by = db.relationship(User,
                                 lazy='dynamic',
                                 secondary=starred_job_table,
                                 backref=db.backref('starred_jobs',
                                                    lazy='dynamic'))
    #: Quick lookup locations this post is referring to
    geonameids = association_proxy('locations',
                                   'geonameid',
                                   creator=lambda l: JobLocation(geonameid=l))

    _defercols = [
        defer('user_id'),
        defer('closed_datetime'),
        defer('parsed_location'),
        defer('relocation_assist'),
        defer('description'),
        defer('perks'),
        defer('how_to_apply'),
        defer('hr_contact'),
        defer('company_logo'),
        defer('company_url'),
        defer('fullname'),
        defer('email'),
        defer('words'),
        defer('promocode'),
        defer('ipaddr'),
        defer('useragent'),
        defer('edit_key'),
        defer('email_verify_key'),
        defer('email_sent'),
        defer('email_verified'),
        defer('payment_value'),
        defer('payment_received'),
        defer('reviewer_id'),
        defer('review_datetime'),
        defer('review_comments'),
        defer('language'),
        defer('language_confidence'),

        # These are defined below JobApplication
        defer('new_applications'),
        defer('replied_applications'),
        defer('viewcounts_viewed'),
        defer('viewcounts_opened'),
        defer('viewcounts_applied'),

        # defer('pay_type'),
        # defer('pay_currency'),
        # defer('pay_cash_min'),
        # defer('pay_cash_max'),
        # defer('pay_equity_min'),
        # defer('pay_equity_max'),
    ]

    @classmethod
    def get(cls, hashid):
        return cls.query.filter_by(hashid=hashid).one_or_none()

    @classmethod
    def fetch(cls, hashid):
        """Returns a SQLAlchemy query object for JobPost"""
        return cls.query.filter_by(hashid=hashid).options(
            load_only('id', 'headline', 'headlineb', 'hashid', 'datetime',
                      '_state', 'email_domain', 'review_comments',
                      'company_url'))

    @classmethodproperty
    def query_listed(cls):
        """Returns a SQLAlchemy query for listed jobposts"""
        return cls.query.filter(JobPost.state.LISTED).options(
            db.load_only('id', 'hashid'))

    def __repr__(self):
        return '<JobPost {hashid} "{headline}">'.format(
            hashid=self.hashid, headline=self.headline.encode('utf-8'))

    def admin_is(self, user):
        if user is None:
            return False
        return user == self.user or bool(
            self.admins.options(
                db.load_only('id')).filter_by(id=user.id).count())

    @property
    def expiry_date(self):
        return self.datetime + agelimit

    @property
    def after_expiry_date(self):
        return self.expiry_date + timedelta(days=1)

    # NEW = Posted within last 24 hours
    state.add_conditional_state(
        'NEW',
        state.PUBLIC,
        lambda jobpost: jobpost.datetime >= datetime.utcnow() - newlimit,
        label=('new', __("New!")))
    # LISTED = Posted within last 30 days
    state.add_conditional_state(
        'LISTED',
        state.PUBLIC,
        lambda jobpost: jobpost.datetime >= datetime.utcnow() - agelimit,
        label=('listed', __("Listed")))
    # OLD = Posted more than 30 days ago
    state.add_conditional_state('OLD',
                                state.PUBLIC,
                                lambda jobpost: not jobpost.state.LISTED,
                                label=('old', __("Old")))
    # Checks if current user has the permission to confirm the jobpost
    state.add_conditional_state(
        'CONFIRMABLE',
        state.UNPUBLISHED,
        lambda jobpost: jobpost.current_permissions.edit,
        label=('confirmable', __("Confirmable")))

    @state.transition(state.PUBLIC,
                      state.WITHDRAWN,
                      title=__("Withdraw"),
                      message=__("This job post has been withdrawn"),
                      type='danger')
    def withdraw(self):
        self.closed_datetime = db.func.utcnow()

    @state.transition(state.PUBLIC,
                      state.CLOSED,
                      title=__("Close"),
                      message=__("This job post has been closed"),
                      type='danger')
    def close(self):
        self.closed_datetime = db.func.utcnow()

    @state.transition(state.UNPUBLISHED_OR_MODERATED,
                      state.CONFIRMED,
                      title=__("Confirm"),
                      message=__("This job post has been confirmed"),
                      type='success')
    def confirm(self):
        self.email_verified = True
        self.datetime = db.func.utcnow()

    @state.transition(state.CLOSED,
                      state.CONFIRMED,
                      title=__("Reopen"),
                      message=__("This job post has been reopened"),
                      type='success')
    def reopen(self):
        pass

    @state.transition(state.PUBLIC,
                      state.SPAM,
                      title=__("Mark as spam"),
                      message=__("This job post has been marked as spam"),
                      type='danger')
    def mark_spam(self, reason, user):
        self.closed_datetime = db.func.utcnow()
        self.review_datetime = db.func.utcnow()
        self.review_comments = reason
        self.reviewer = user

    @state.transition(
        state.DRAFT,
        state.PENDING,
        title=__("Mark as pending"),
        message=__("This job post is awaiting email verification"),
        type='danger')
    def mark_pending(self):
        pass

    @state.transition(state.PUBLIC,
                      state.REJECTED,
                      title=__("Reject"),
                      message=__("This job post has been rejected"),
                      type='danger')
    def reject(self, reason, user):
        self.closed_datetime = db.func.utcnow()
        self.review_datetime = db.func.utcnow()
        self.review_comments = reason
        self.reviewer = user

    @state.transition(state.PUBLIC,
                      state.MODERATED,
                      title=__("Moderate"),
                      message=__("This job post has been moderated"),
                      type='primary')
    def moderate(self, reason, user):
        self.closed_datetime = db.func.utcnow()
        self.review_datetime = db.func.utcnow()
        self.review_comments = reason
        self.reviewer = user

    def url_for(self, action='view', _external=False, **kwargs):
        if self.state.UNPUBLISHED and action in ('view', 'edit'):
            domain = None
        else:
            domain = self.email_domain

        # A/B test flag for permalinks
        if 'b' in kwargs:
            if kwargs['b'] is not None:
                kwargs['b'] = unicode(int(kwargs['b']))
            else:
                kwargs.pop('b')

        if action == 'view':
            return url_for('jobdetail',
                           hashid=self.hashid,
                           domain=domain,
                           _external=_external,
                           **kwargs)
        elif action == 'reveal':
            return url_for('revealjob',
                           hashid=self.hashid,
                           domain=domain,
                           _external=_external,
                           **kwargs)
        elif action == 'apply':
            return url_for('applyjob',
                           hashid=self.hashid,
                           domain=domain,
                           _external=_external,
                           **kwargs)
        elif action == 'edit':
            return url_for('editjob',
                           hashid=self.hashid,
                           domain=domain,
                           _external=_external,
                           **kwargs)
        elif action == 'withdraw':
            return url_for('withdraw',
                           hashid=self.hashid,
                           domain=domain,
                           _external=_external,
                           **kwargs)
        elif action == 'close':
            return url_for('close',
                           hashid=self.hashid,
                           domain=domain,
                           _external=_external,
                           **kwargs)
        elif action == 'viewstats':
            return url_for('job_viewstats',
                           hashid=self.hashid,
                           domain=domain,
                           _external=_external,
                           **kwargs)
        elif action == 'related_posts':
            return url_for('job_related_posts',
                           hashid=self.hashid,
                           domain=domain,
                           _external=_external,
                           **kwargs)
        elif action == 'reopen':
            return url_for('reopen',
                           hashid=self.hashid,
                           domain=domain,
                           _external=_external,
                           **kwargs)
        elif action == 'moderate':
            return url_for('moderatejob',
                           hashid=self.hashid,
                           domain=domain,
                           _external=_external,
                           **kwargs)
        elif action == 'pin':
            return url_for('pinnedjob',
                           hashid=self.hashid,
                           domain=domain,
                           _external=_external,
                           **kwargs)
        elif action == 'reject':
            return url_for('rejectjob',
                           hashid=self.hashid,
                           domain=domain,
                           _external=_external,
                           **kwargs)
        elif action == 'confirm':
            return url_for('confirm',
                           hashid=self.hashid,
                           _external=_external,
                           **kwargs)
        elif action == 'logo':
            return url_for('logoimage',
                           hashid=self.hashid,
                           domain=domain,
                           _external=_external,
                           **kwargs)
        elif action == 'confirm-link':
            return url_for('confirm_email',
                           hashid=self.hashid,
                           domain=domain,
                           key=self.email_verify_key,
                           _external=True,
                           **kwargs)
        elif action == 'star':
            return url_for('starjob',
                           hashid=self.hashid,
                           domain=domain,
                           _external=_external,
                           **kwargs)
        elif action == 'manage':
            return url_for('managejob',
                           hashid=self.hashid,
                           domain=domain,
                           _external=_external,
                           **kwargs)
        elif action == 'browse':
            if is_public_email_domain(self.email_domain, default=False):
                return url_for('browse_by_email',
                               md5sum=self.md5sum,
                               _external=_external,
                               **kwargs)
            else:
                return url_for('browse_by_domain',
                               domain=self.email_domain,
                               _external=_external,
                               **kwargs)

    def permissions(self, user, inherited=None):
        perms = super(JobPost, self).permissions(user, inherited)
        if self.state.PUBLIC:
            perms.add('view')
        if self.admin_is(user):
            if self.state.UNPUBLISHED:
                perms.add('view')
            perms.add('edit')
            perms.add('manage')
            perms.add('withdraw')
        return perms

    @property
    def from_webmail_domain(self):
        return is_public_email_domain(self.email_domain, default=False)

    @property
    def company_url_domain_zone(self):
        if not self.company_url:
            return u''
        else:
            r = tldextract.extract(self.company_url)
            return u'.'.join([r.domain, r.suffix])

    @property
    def pays_cash(self):
        if self.pay_type is None:  # Legacy record from before `pay_type` was mandatory
            return True
        return self.pay_type != PAY_TYPE.NOCASH and self.pay_cash_min is not None and self.pay_cash_max is not None

    @property
    def pays_equity(self):
        return self.pay_equity_min is not None and self.pay_equity_max is not None

    def pay_label(self):
        if self.pay_type is None:
            return u"NA"
        elif self.pay_type == PAY_TYPE.NOCASH:
            cash = None
            suffix = ""
        else:
            if self.pay_type == PAY_TYPE.RECURRING:
                suffix = "pa"
            else:
                suffix = ""

            indian = False
            if self.pay_currency == "INR":
                indian = True
                symbol = u"₹"
            elif self.pay_currency == "USD":
                symbol = u"$"
            elif self.pay_currency == "EUR":
                symbol = u"€"
            elif self.pay_currency == "GBP":
                symbol = u"£"
            else:
                symbol = u"¤"

            if self.pay_cash_min == self.pay_cash_max:
                cash = symbol + number_abbreviate(self.pay_cash_min, indian)
            else:
                cash = symbol + number_abbreviate(
                    self.pay_cash_min, indian) + "-" + number_abbreviate(
                        self.pay_cash_max, indian)

            if suffix:
                cash = cash + " " + suffix

        if self.pays_equity:
            if self.pay_equity_min == self.pay_equity_max:
                equity = str(self.pay_equity_min) + "%"
            else:
                equity = str(self.pay_equity_min) + "-" + str(
                    self.pay_equity_max) + "%"
        else:
            equity = None

        if cash:
            if equity:
                return ", ".join([cash, equity])
            else:
                return cash
        else:
            if equity:
                return equity
            else:
                return "No pay"

    def tag_content(self):
        return Markup('\n').join(
            (Markup('<div>') + Markup(escape(self.headline)) +
             Markup('</div>'),
             Markup('<div>') + Markup(self.description) + Markup('</div>'),
             Markup('<div>') + Markup(self.perks) + Markup('</div>')))

    @staticmethod
    def viewcounts_key(jobpost_id):
        if isinstance(jobpost_id, (list, tuple)):
            return ['hasjob/viewcounts/%d' % post_id for post_id in jobpost_id]
        return 'hasjob/viewcounts/%d' % jobpost_id

    def uncache_viewcounts(self, key=None):
        cache_key = JobPost.viewcounts_key(self.id)
        if not key:
            redis_store.delete(cache_key)
        else:
            redis_store.hdel(cache_key, key)

    @cached_property
    def ab_impressions(self):
        results = {'NA': 0, 'A': 0, 'B': 0}
        counts = db.session.query(JobImpression.bgroup.label('bgroup'),
                                  db.func.count('*').label('count')).filter(
                                      JobImpression.jobpost == self).group_by(
                                          JobImpression.bgroup)
        for row in counts:
            if row.bgroup is False:
                results['A'] = row.count
            elif row.bgroup is True:
                results['B'] = row.count
            else:
                results['NA'] = row.count
        return results

    @cached_property
    def ab_views(self):
        results = {
            'C_NA': 0,
            'C_A': 0,
            'C_B': 0,  # Conversions (cointoss=True, crosstoss=False)
            'E_NA': 0,
            'E_A': 0,
            'E_B':
            0,  # External (cointoss=False, crosstoss=True OR False [do sum])
            'X_NA': 0,
            'X_A': 0,
            'X_B': 0,  # Cross toss (cointoss=True, crosstoss=True)
        }
        counts = db.session.query(JobViewSession.bgroup.label('bgroup'),
                                  JobViewSession.cointoss.label('cointoss'),
                                  JobViewSession.crosstoss.label('crosstoss'),
                                  db.func.count('*').label('count')).filter(
                                      JobViewSession.jobpost == self).group_by(
                                          JobViewSession.bgroup,
                                          JobViewSession.cointoss,
                                          JobViewSession.crosstoss)

        for row in counts:
            if row.cointoss is True and row.crosstoss is False:
                prefix = 'C'
            elif row.cointoss is False:
                prefix = 'E'
            elif row.cointoss is True and row.crosstoss is True:
                prefix = 'X'
            if row.bgroup is False:
                results[prefix + '_A'] += row.count
            elif row.bgroup is True:
                results[prefix + '_B'] += row.count
            else:
                results[prefix + '_NA'] += row.count
        return results

    @property
    def sort_score(self):
        """
        Sort with a gravity of 1.8 using the HackerNews algorithm
        """
        viewcounts = self.viewcounts
        opened = int(viewcounts['opened'])
        applied = int(viewcounts['applied'])
        age = datetime.utcnow() - self.datetime
        hours = age.days * 24 + age.seconds / 3600

        return ((applied * 3) + (opened - applied)) / pow((hours + 2), 1.8)

    @cached_property  # For multiple accesses in a single request
    def viewstats(self):
        now = datetime.utcnow()
        delta = now - self.datetime
        hourly_stat_limit = 2  # days
        if delta.days < hourly_stat_limit:  # Less than {limit} days
            return 'h', viewstats_by_id_hour(self.id, hourly_stat_limit * 24)
        else:
            return 'd', viewstats_by_id_day(self.id, 30)

    def reports(self):
        if not self.flags:
            return []
        counts = {}
        for flag in self.flags:
            counts[flag.reportcode] = counts.setdefault(flag.reportcode, 0) + 1
        return [{
            'count': i[2],
            'title': i[1]
        } for i in sorted([(k.seq, k.title, v) for k, v in counts.items()])]
Example #4
0
class MyPost(BaseMixin, db.Model):
    __tablename__ = 'my_post'
    # Database state columns
    _state = db.Column(
        'state',
        db.Integer,
        StateManager.check_constraint('state', MY_STATE),
        default=MY_STATE.DRAFT,
        nullable=False,
    )
    _reviewstate = db.Column(
        'reviewstate',
        db.Integer,
        StateManager.check_constraint('state', REVIEW_STATE),
        default=REVIEW_STATE.UNSUBMITTED,
        nullable=False,
    )
    # State managers
    state = StateManager('_state', MY_STATE, doc="The post's state")
    reviewstate = StateManager('_reviewstate',
                               REVIEW_STATE,
                               doc="Reviewer's state")

    # We do not use the LabeledEnum from now on. States must be accessed from the
    # state manager instead.

    # Model's data columns (used for tests)
    datetime = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)

    # Conditional states (adds ManagedState instances)
    state.add_conditional_state(
        'RECENT',
        state.PUBLISHED,
        lambda post: post.datetime > datetime.utcnow() - timedelta(hours=1),
        label=('recent', "Recently published"),
    )

    # State groups (apart from those in the LabeledEnum), used here to include the
    # conditional state in a group. Adds ManagedStateGroup instances
    state.add_state_group('REDRAFTABLE', state.DRAFT, state.PENDING,
                          state.RECENT)

    # State transitions. When multiple state managers are involved, all of them
    # must be in a matching "from" state for the transition to be valid.
    # Specifying `None` for "from" state indicates that any "from" state is valid.
    @with_roles(call={'author'})
    @state.transition(state.DRAFT, state.PENDING)
    @reviewstate.transition(None, reviewstate.UNSUBMITTED, title="Submit")
    def submit(self):
        pass

    @with_roles(call={'author'})
    @state.transition(state.UNPUBLISHED, state.PUBLISHED)
    @reviewstate.transition(reviewstate.UNLOCKED,
                            reviewstate.PENDING,
                            title="Publish")
    def publish(self):
        if self.state.DRAFT:
            # Use AssertionError to distinguish from the wrapper's StateTransitionError (a TypeError) in tests below
            raise AssertionError(
                "We don't actually support transitioning from draft to published"
            )
        self.datetime = datetime.utcnow()

    @with_roles(call={'author'})
    @state.transition(state.RECENT, state.PENDING, title="Undo")
    @reviewstate.transition(reviewstate.UNLOCKED, reviewstate.UNSUBMITTED)
    def undo(self):
        pass

    @with_roles(call={'author'})
    @state.transition(state.REDRAFTABLE, state.DRAFT, title="Redraft")
    @reviewstate.transition(reviewstate.UNLOCKED, reviewstate.UNSUBMITTED)
    def redraft(self):
        pass

    @with_roles(call={'reviewer'})
    @reviewstate.transition(reviewstate.UNLOCKED,
                            reviewstate.LOCKED,
                            if_=state.PUBLISHED,
                            title="Lock")
    def review_lock(self):
        pass

    @with_roles(call={'reviewer'})
    @reviewstate.transition(reviewstate.LOCKED,
                            reviewstate.PENDING,
                            title="Unlock")
    def review_unlock(self):
        pass

    @with_roles(call={'reviewer'})
    @state.requires(state.PUBLISHED, title="Rewind 2 hours")
    def rewind(self):
        self.datetime = datetime.utcnow() - timedelta(hours=2)

    @with_roles(call={'author'})
    @state.transition(state.UNPUBLISHED,
                      state.PUBLISHED,
                      message=u"Abort this transition")
    @reviewstate.transition(reviewstate.UNLOCKED,
                            reviewstate.PENDING,
                            title="Publish")
    def abort(self, success=False, empty_abort=False):
        if not success:
            if empty_abort:
                raise AbortTransition()
            raise AbortTransition((success, 'failed'))
        return success, 'passed'

    def roles_for(self, actor=None, anchors=()):
        roles = super(MyPost, self).roles_for(actor, anchors)
        # Cheap hack for the sake of testing, using strings instead of objects
        if actor == 'author':
            roles.add('author')
        if actor == 'reviewer':
            roles.add('reviewer')
        return roles
Example #5
0
class Campaign(BaseNameMixin, db.Model):
    """
    A campaign runs in the header or sidebar of Hasjob's pages and prompts the user towards some action.
    Unlike announcements, campaigns sit outside the content area of listings.
    """

    __tablename__ = 'campaign'

    # Campaign metadata columns

    #: User who created this campaign
    user_id = db.Column(None, db.ForeignKey('user.id'), nullable=False)
    user = db.relationship(User, backref='campaigns')
    #: When does this campaign go on air?
    start_at = db.Column(db.TIMESTAMP(timezone=True), nullable=False, index=True)
    #: When does it go off air?
    end_at = db.Column(db.TIMESTAMP(timezone=True), nullable=False, index=True)
    #: Is this campaign live?
    public = db.Column(db.Boolean, nullable=False, default=False)
    #: StateManager for campaign's state
    state = StateManager('public', CAMPAIGN_STATE)
    #: Position to display campaign in
    position = db.Column(
        db.SmallInteger, nullable=False, default=CAMPAIGN_POSITION.HEADER
    )
    #: Boards to run this campaign on
    boards = db.relationship(Board, secondary=board_campaign_table)
    #: Quick lookup locations to run this campaign in
    geonameids = association_proxy(
        'locations', 'geonameid', creator=lambda l: CampaignLocation(geonameid=l)
    )
    #: Is this campaign location-based?
    geotargeted = db.Column(db.Boolean, nullable=False, default=False)
    #: Is a user required? None = don't care, True = user required, False = no user
    user_required = db.Column(db.Boolean, nullable=True, default=None)
    #: Priority, lower = less priority
    priority = db.Column(db.Integer, nullable=False, default=0)

    # Campaign content columns

    #: Subject (user-facing, unlike the title)
    subject = db.Column(db.Unicode(250), nullable=True)
    #: Call to action text (for header campaigns)
    blurb = db.Column(db.UnicodeText, nullable=False, default='')
    #: Full text (for read more click throughs)
    description = db.Column(db.UnicodeText, nullable=False, default='')
    #: Banner image
    banner_image = db.Column(db.Unicode(250), nullable=True)
    #: Banner location
    banner_location = db.Column(
        db.SmallInteger, nullable=False, default=BANNER_LOCATION.TOP
    )

    # Flags
    flag_is_new_since_day = db.Column(db.Boolean, nullable=True)
    flag_is_new_since_month = db.Column(db.Boolean, nullable=True)
    flag_is_not_new = db.Column(db.Boolean, nullable=True)

    flag_is_candidate_alltime = db.Column(db.Boolean, nullable=True)
    flag_is_candidate_day = db.Column(db.Boolean, nullable=True)
    flag_is_candidate_month = db.Column(db.Boolean, nullable=True)
    flag_is_candidate_past = db.Column(db.Boolean, nullable=True)

    flag_has_jobapplication_response_alltime = db.Column(db.Boolean, nullable=True)
    flag_has_jobapplication_response_day = db.Column(db.Boolean, nullable=True)
    flag_has_jobapplication_response_month = db.Column(db.Boolean, nullable=True)
    flag_has_jobapplication_response_past = db.Column(db.Boolean, nullable=True)

    flag_is_employer_alltime = db.Column(db.Boolean, nullable=True)
    flag_is_employer_day = db.Column(db.Boolean, nullable=True)
    flag_is_employer_month = db.Column(db.Boolean, nullable=True)
    flag_is_employer_past = db.Column(db.Boolean, nullable=True)

    flag_has_jobpost_unconfirmed_alltime = db.Column(db.Boolean, nullable=True)
    flag_has_jobpost_unconfirmed_day = db.Column(db.Boolean, nullable=True)
    flag_has_jobpost_unconfirmed_month = db.Column(db.Boolean, nullable=True)

    flag_has_responded_candidate_alltime = db.Column(db.Boolean, nullable=True)
    flag_has_responded_candidate_day = db.Column(db.Boolean, nullable=True)
    flag_has_responded_candidate_month = db.Column(db.Boolean, nullable=True)
    flag_has_responded_candidate_past = db.Column(db.Boolean, nullable=True)

    flag_is_new_lurker_within_day = db.Column(db.Boolean, nullable=True)
    flag_is_new_lurker_within_month = db.Column(db.Boolean, nullable=True)
    flag_is_lurker_since_past = db.Column(db.Boolean, nullable=True)
    flag_is_lurker_since_alltime = db.Column(db.Boolean, nullable=True)
    flag_is_inactive_since_day = db.Column(db.Boolean, nullable=True)
    flag_is_inactive_since_month = db.Column(db.Boolean, nullable=True)

    # Sessions this campaign has been viewed in
    session_views = db.relationship(
        EventSession,
        secondary=campaign_event_session_table,
        backref='campaign_views',
        order_by=campaign_event_session_table.c.created_at,
        lazy='dynamic',
    )

    __table_args__ = (
        db.CheckConstraint('end_at > start_at', name='campaign_start_at_end_at'),
    )

    # Campaign conditional states
    state.add_conditional_state(
        'LIVE',
        state.ENABLED,
        lambda obj: obj.start_at <= utcnow() < obj.end_at,
        lambda cls: db.and_(
            cls.start_at <= db.func.utcnow(), cls.end_at > db.func.utcnow()
        ),
        label=('live', __("Live")),
    )
    state.add_conditional_state(
        'CURRENT',
        state.ENABLED,
        lambda obj: obj.start_at
        <= obj.start_at
        <= utcnow()
        < obj.end_at
        <= utcnow() + timedelta(days=30),
        lambda cls: db.and_(
            cls.start_at <= db.func.utcnow(),
            cls.end_at > db.func.utcnow(),
            cls.end_at <= utcnow() + timedelta(days=30),
        ),
        label=('current', __("Current")),
    )
    state.add_conditional_state(
        'LONGTERM',
        state.ENABLED,
        lambda obj: obj.start_at
        <= obj.start_at
        <= utcnow()
        < utcnow() + timedelta(days=30)
        < obj.end_at,
        lambda cls: db.and_(
            cls.start_at <= utcnow(), cls.end_at > utcnow() + timedelta(days=30)
        ),
        label=('longterm', __("Long term")),
    )
    state.add_conditional_state(
        'OFFLINE',
        state.ENABLED,
        lambda obj: obj.start_at > utcnow() or obj.end_at <= utcnow(),
        lambda cls: db.or_(
            cls.start_at > db.func.utcnow(), cls.end_at <= db.func.utcnow()
        ),
        label=('offline', __("Offline")),
    )

    @property
    def content(self):
        """Form helper method"""
        return self

    @property
    def flags(self):
        """Form helper method"""
        return self

    def __repr__(self):
        return '<Campaign %s "%s" %s:%s>' % (
            self.name,
            self.title,
            self.start_at.strftime('%Y-%m-%d'),
            self.end_at.strftime('%Y-%m-%d'),
        )

    def useractions(self, user):
        if user is not None:
            return {
                cua.action.name: cua
                for cua in CampaignUserAction.query.filter_by(user=user)
                .filter(CampaignUserAction.action_id.in_([a.id for a in self.actions]))
                .all()
            }
        else:
            return {}

    def view_for(self, user=None, anon_user=None):
        if user:
            return CampaignView.get(campaign=self, user=user)
        elif anon_user:
            return CampaignAnonView.get(campaign=self, anon_user=anon_user)

    def subject_for(self, user):
        return self.subject.format(user=user)

    def blurb_for(self, user):
        return Markup(self.blurb).format(user=user)

    def description_for(self, user):
        return Markup(self.description).format(user=user)

    def estimated_reach(self):
        """
        Returns the number of users this campaign could potentially reach (assuming users are all active)
        """
        plus_userids = set()
        minus_userids = set()
        for flag in Campaign.supported_flags:
            setting = getattr(self, 'flag_' + flag)
            if setting is True or setting is False:
                query = getattr(UserFlags, flag).user_ids()
            if setting is True:
                userids = set(query.all())
                if plus_userids:
                    plus_userids = plus_userids.intersection(userids)
                else:
                    plus_userids = userids
            elif setting is False:
                userids = set(db.session.query(~User.id.in_(query)).all())
                if minus_userids:
                    minus_userids = minus_userids.union(userids)
                else:
                    minus_userids = userids
        if not plus_userids and not minus_userids:
            return None
        return len(plus_userids - minus_userids)

    def form(self):
        """Convenience method for action form CSRF"""
        return Form()

    @classmethod
    def for_context(
        cls, position, board=None, user=None, anon_user=None, geonameids=None
    ):
        """
        Return a campaign suitable for this board, user and locations (as geonameids).
        """
        basequery = cls.query.filter(cls.state.LIVE, cls.position == position)

        if board:
            basequery = basequery.filter(cls.boards.any(id=board.id))

        if user:
            # Look for campaigns that don't target by user or require a user
            basequery = basequery.filter(
                db.or_(cls.user_required.is_(None), cls.user_required.is_(True))
            )
        else:
            # Look for campaigns that don't target by user or require no user
            basequery = basequery.filter(
                db.or_(cls.user_required.is_(None), cls.user_required.is_(False))
            )

        if geonameids:
            # TODO: The query for CampaignLocation.campaign_id here does not consider
            # if the campaign id is currently live. This will become inefficient as the
            # number of location-targeted campaigns grows. This should be cached.
            basequery = basequery.filter(
                db.or_(
                    cls.geotargeted.is_(False),
                    db.and_(
                        cls.geotargeted.is_(True),
                        cls.id.in_(
                            db.session.query(CampaignLocation.campaign_id).filter(
                                CampaignLocation.geonameid.in_(geonameids)
                            )
                        ),
                    ),
                )
            )

            # In the following simpler version, a low priority geotargeted campaign returns above a high priority
            # non-geotargeted campaign, which isn't the intended behaviour. We've therefore commented it out and
            # left it here for reference.

            # basequery = basequery.join(CampaignLocation).filter(db.or_(
            #     cls.geotargeted.is_(False),
            #     db.and_(
            #         cls.geotargeted.is_(True),
            #         CampaignLocation.geonameid.in_(geonameids)
            #     )))
        else:
            basequery = basequery.filter(cls.geotargeted.is_(False))

        # Don't show campaigns that (a) the user has dismissed or (b) the user has encountered on >2 event sessions
        if user is not None:
            # TODO: The more campaigns we run, the more longer this list gets. Find something more efficient
            basequery = basequery.filter(
                ~cls.id.in_(
                    db.session.query(CampaignView.campaign_id).filter(
                        CampaignView.user == user,
                        db.or_(
                            CampaignView.dismissed.is_(True),
                            CampaignView.session_count > 2,
                        ),
                    )
                )
            )

            # Filter by user flags
            for flag, value in user.flags.items():
                if flag in cls.supported_flags:
                    col = getattr(cls, 'flag_' + flag)
                    basequery = basequery.filter(db.or_(col.is_(None), col == value))

        else:
            if anon_user:
                basequery = basequery.filter(
                    ~cls.id.in_(
                        db.session.query(CampaignAnonView.campaign_id).filter(
                            CampaignAnonView.anon_user == anon_user,
                            db.or_(
                                CampaignAnonView.dismissed.is_(True),
                                CampaignAnonView.session_count > 2,
                            ),
                        )
                    )
                )
            # Don't show user-targeted campaigns if there's no user
            basequery = basequery.filter_by(
                **{'flag_' + flag: None for flag in cls.supported_flags}
            )

        return basequery.order_by(cls.priority.desc()).first()

    @classmethod
    def get(cls, name):
        return cls.query.filter_by(name=name).one_or_none()
Example #6
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
Example #7
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
Example #8
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
Example #9
0
class Update(UuidMixin, BaseScopedIdNameMixin, TimestampMixin, db.Model):
    __tablename__ = 'update'

    _visibility_state = db.Column(
        'visibility_state',
        db.SmallInteger,
        StateManager.check_constraint('visibility_state', VISIBILITY_STATE),
        default=VISIBILITY_STATE.PUBLIC,
        nullable=False,
        index=True,
    )
    visibility_state = StateManager('_visibility_state',
                                    VISIBILITY_STATE,
                                    doc="Visibility state")

    _state = db.Column(
        'state',
        db.SmallInteger,
        StateManager.check_constraint('state', UPDATE_STATE),
        default=UPDATE_STATE.DRAFT,
        nullable=False,
        index=True,
    )
    state = StateManager('_state', UPDATE_STATE, doc="Update state")

    user_id = db.Column(None,
                        db.ForeignKey('user.id'),
                        nullable=False,
                        index=True)
    user = with_roles(
        db.relationship(User,
                        backref=db.backref('updates', lazy='dynamic'),
                        foreign_keys=[user_id]),
        read={'all'},
        grants={'creator'},
    )

    project_id = db.Column(None,
                           db.ForeignKey('project.id'),
                           nullable=False,
                           index=True)
    project = with_roles(
        db.relationship(Project, backref=db.backref('updates',
                                                    lazy='dynamic')),
        read={'all'},
        grants_via={
            None: {
                'editor': {'editor', 'project_editor'},
                'participant': {'reader', 'project_participant'},
                'crew': {'reader', 'project_crew'},
            }
        },
    )
    parent = db.synonym('project')

    body = MarkdownColumn('body', nullable=False)

    #: Update number, for Project updates, assigned when the update is published
    number = with_roles(db.Column(db.Integer, nullable=True, default=None),
                        read={'all'})

    #: Like pinned tweets. You can keep posting updates,
    #: but might want to pin an update from a week ago.
    is_pinned = with_roles(db.Column(db.Boolean, default=False,
                                     nullable=False),
                           read={'all'})

    published_by_id = db.Column(None,
                                db.ForeignKey('user.id'),
                                nullable=True,
                                index=True)
    published_by = with_roles(
        db.relationship(
            User,
            backref=db.backref('published_updates', lazy='dynamic'),
            foreign_keys=[published_by_id],
        ),
        read={'all'},
    )
    published_at = with_roles(db.Column(db.TIMESTAMP(timezone=True),
                                        nullable=True),
                              read={'all'})

    deleted_by_id = db.Column(None,
                              db.ForeignKey('user.id'),
                              nullable=True,
                              index=True)
    deleted_by = with_roles(
        db.relationship(
            User,
            backref=db.backref('deleted_updates', lazy='dynamic'),
            foreign_keys=[deleted_by_id],
        ),
        read={'reader'},
    )
    deleted_at = with_roles(db.Column(db.TIMESTAMP(timezone=True),
                                      nullable=True),
                            read={'reader'})

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

    voteset_id = db.Column(None, db.ForeignKey('voteset.id'), nullable=False)
    voteset = with_roles(db.relationship(Voteset, uselist=False), read={'all'})

    commentset_id = db.Column(None,
                              db.ForeignKey('commentset.id'),
                              nullable=False)
    commentset = with_roles(
        db.relationship(
            Commentset,
            uselist=False,
            lazy='joined',
            cascade='all',
            single_parent=True,
            backref=db.backref('update', uselist=False),
        ),
        read={'all'},
    )

    search_vector = db.deferred(
        db.Column(
            TSVectorType(
                'name',
                'title',
                'body_text',
                weights={
                    'name': 'A',
                    'title': 'A',
                    'body_text': 'B'
                },
                regconfig='english',
                hltext=lambda: db.func.concat_ws(visual_field_delimiter, Update
                                                 .title, Update.body_html),
            ),
            nullable=False,
        ))

    __roles__ = {
        'all': {
            'read': {'name', 'title', 'urls'},
            'call': {'features', 'visibility_state', 'state', 'url_for'},
        },
        'reader': {
            'read': {'body'}
        },
    }

    __datasets__ = {
        'primary': {
            'name',
            'title',
            'number',
            'body',
            'body_text',
            'body_html',
            'published_at',
            'edited_at',
            'user',
            'is_pinned',
            'is_restricted',
            'is_currently_restricted',
            'visibility_label',
            'state_label',
            'urls',
            'uuid_b58',
        },
        'related': {'name', 'title', 'urls'},
    }

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.voteset = Voteset(settype=SET_TYPE.UPDATE)
        self.commentset = Commentset(settype=SET_TYPE.UPDATE)

    def __repr__(self):
        return '<Update "{title}" {uuid_b58}>'.format(title=self.title,
                                                      uuid_b58=self.uuid_b58)

    @with_roles(read={'all'})
    @property
    def visibility_label(self):
        return self.visibility_state.label.title

    @with_roles(read={'all'})
    @property
    def state_label(self):
        return self.state.label.title

    state.add_conditional_state(
        'UNPUBLISHED',
        state.DRAFT,
        lambda update: update.published_at is None,
        lambda update: update.published_at.is_(None),
        label=('unpublished', __("Unpublished")),
    )

    state.add_conditional_state(
        'WITHDRAWN',
        state.DRAFT,
        lambda update: update.published_at is not None,
        lambda update: update.published_at.isnot(None),
        label=('withdrawn', __("Withdrawn")),
    )

    @with_roles(call={'editor'})
    @state.transition(state.DRAFT, state.PUBLISHED)
    def publish(self, actor):
        first_publishing = False
        self.published_by = actor
        if self.published_at is None:
            first_publishing = True
            self.published_at = db.func.utcnow()
        if self.number is None:
            self.number = db.select([
                db.func.coalesce(db.func.max(Update.number), 0) + 1
            ]).where(Update.project == self.project)
        return first_publishing

    @with_roles(call={'editor'})
    @state.transition(state.PUBLISHED, state.DRAFT)
    def undo_publish(self):
        pass

    @with_roles(call={'creator', 'editor'})
    @state.transition(None, state.DELETED)
    def delete(self, actor):
        if self.state.UNPUBLISHED:
            # If it was never published, hard delete it
            db.session.delete(self)
        else:
            # If not, then soft delete
            self.deleted_by = actor
            self.deleted_at = db.func.utcnow()

    @with_roles(call={'editor'})
    @state.transition(state.DELETED, state.DRAFT)
    def undo_delete(self):
        self.deleted_by = None
        self.deleted_at = None

    @with_roles(call={'editor'})
    @visibility_state.transition(visibility_state.RESTRICTED,
                                 visibility_state.PUBLIC)
    def make_public(self):
        pass

    @with_roles(call={'editor'})
    @visibility_state.transition(visibility_state.PUBLIC,
                                 visibility_state.RESTRICTED)
    def make_restricted(self):
        pass

    @with_roles(read={'all'})
    @property
    def is_restricted(self):
        return bool(self.visibility_state.RESTRICTED)

    @is_restricted.setter
    def is_restricted(self, value):
        if value and self.visibility_state.PUBLIC:
            self.make_restricted()
        elif not value and self.visibility_state.RESTRICTED:
            self.make_public()

    @with_roles(read={'all'})
    @property
    def is_currently_restricted(self):
        return self.is_restricted and not self.current_roles.reader

    def roles_for(self, actor=None, anchors=()):
        roles = super().roles_for(actor, anchors)
        if not self.visibility_state.RESTRICTED:
            # Everyone gets reader role when the post is not restricted.
            # If it is, 'reader' must be mapped from 'participant' in the project,
            # specified above in the grants_via annotation on project.
            roles.add('reader')

        return roles
Example #10
0
class Proposal(
        UuidMixin,
        EmailAddressMixin,
        BaseScopedIdNameMixin,
        CoordinatesMixin,
        VideoMixin,
        db.Model,
):
    __tablename__ = 'proposal'
    __email_for__ = 'owner'

    user_id = db.Column(None, db.ForeignKey('user.id'), nullable=False)
    user = with_roles(
        db.relationship(
            User,
            primaryjoin=user_id == User.id,
            backref=db.backref('proposals', cascade='all', lazy='dynamic'),
        ),
        grants={'creator'},
    )

    speaker_id = db.Column(None, db.ForeignKey('user.id'), nullable=True)
    speaker = with_roles(
        db.relationship(
            User,
            primaryjoin=speaker_id == User.id,
            lazy='joined',
            backref=db.backref('speaker_at', cascade='all', lazy='dynamic'),
        ),
        grants={'presenter'},
    )

    phone = db.Column(db.Unicode(80), nullable=True)
    bio = MarkdownColumn('bio', nullable=True)
    project_id = db.Column(None, db.ForeignKey('project.id'), nullable=False)
    project = with_roles(
        db.relationship(
            Project,
            primaryjoin=project_id == Project.id,
            backref=db.backref('proposals', cascade='all', lazy='dynamic'),
        ),
        grants_via={None: project_child_role_map},
    )
    parent = db.synonym('project')

    abstract = MarkdownColumn('abstract', nullable=True)
    outline = MarkdownColumn('outline', nullable=True)
    requirements = MarkdownColumn('requirements', nullable=True)
    slides = db.Column(UrlType, nullable=True)
    links = db.Column(db.Text, default='', nullable=True)

    _state = db.Column(
        'state',
        db.Integer,
        StateManager.check_constraint('state', PROPOSAL_STATE),
        default=PROPOSAL_STATE.SUBMITTED,
        nullable=False,
    )
    state = StateManager('_state',
                         PROPOSAL_STATE,
                         doc="Current state of the proposal")

    voteset_id = db.Column(None, db.ForeignKey('voteset.id'), nullable=False)
    voteset = db.relationship(Voteset,
                              uselist=False,
                              lazy='joined',
                              cascade='all',
                              single_parent=True)

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

    edited_at = db.Column(db.TIMESTAMP(timezone=True), nullable=True)
    location = db.Column(db.Unicode(80), nullable=False)

    search_vector = db.deferred(
        db.Column(
            TSVectorType(
                'title',
                'abstract_text',
                'outline_text',
                'requirements_text',
                'slides',
                'links',
                'bio_text',
                weights={
                    'title': 'A',
                    'abstract_text': 'B',
                    'outline_text': 'B',
                    'requirements_text': 'B',
                    'slides': 'B',
                    'links': 'B',
                    'bio_text': 'B',
                },
                regconfig='english',
                hltext=lambda: db.func.concat_ws(
                    visual_field_delimiter,
                    Proposal.title,
                    User.fullname,
                    Proposal.abstract_html,
                    Proposal.outline_html,
                    Proposal.requirements_html,
                    Proposal.links,
                    Proposal.bio_html,
                ),
            ),
            nullable=False,
        ))

    __table_args__ = (
        db.UniqueConstraint('project_id', 'url_id'),
        db.Index('ix_proposal_search_vector',
                 'search_vector',
                 postgresql_using='gin'),
    )

    __roles__ = {
        'all': {
            'read': {
                'urls',
                'uuid_b58',
                'url_name_uuid_b58',
                'title',
                'user',
                'speaker',
                'owner',
                'speaking',
                'bio',
                'abstract',
                'outline',
                'requirements',
                'slides',
                'video',
                'links',
                'location',
                'latitude',
                'longitude',
                'coordinates',
                'session',
                'project',
                'datetime',
            },
            'call': {'url_for', 'state', 'commentset'},
        },
        'reviewer': {
            'read': {'email', 'phone'}
        },
        'project_editor': {
            'read': {'email', 'phone'}
        },
    }

    __datasets__ = {
        'primary': {
            'urls',
            'uuid_b58',
            'url_name_uuid_b58',
            'title',
            'user',
            'speaker',
            'speaking',
            'bio',
            'abstract',
            'outline',
            'requirements',
            'slides',
            'video',
            'links',
            'location',
            'latitude',
            'longitude',
            'coordinates',
            'session',
            'project',
            'email',
            'phone',
        },
        'without_parent': {
            'urls',
            'uuid_b58',
            'url_name_uuid_b58',
            'title',
            'user',
            'speaker',
            'speaking',
            'bio',
            'abstract',
            'outline',
            'requirements',
            'slides',
            'video',
            'links',
            'location',
            'latitude',
            'longitude',
            'coordinates',
            'session',
            'email',
            'phone',
        },
        'related': {'urls', 'uuid_b58', 'url_name_uuid_b58', 'title'},
    }

    def __init__(self, **kwargs):
        super(Proposal, self).__init__(**kwargs)
        self.voteset = Voteset(settype=SET_TYPE.PROPOSAL)
        self.commentset = Commentset(settype=SET_TYPE.PROPOSAL)

    def __repr__(self):
        return '<Proposal "{proposal}" in project "{project}" by "{user}">'.format(
            proposal=self.title,
            project=self.project.title,
            user=self.owner.fullname)

    @db.validates('project')
    def _validate_project(self, key, value):
        if not value:
            raise ValueError(value)

        if value != self.project and self.project is not None:
            redirect = ProposalRedirect.query.get(
                (self.project_id, self.url_id))
            if redirect is None:
                redirect = ProposalRedirect(project=self.project,
                                            url_id=self.url_id,
                                            proposal=self)
                db.session.add(redirect)
            else:
                redirect.proposal = self
        return value

    # State transitions
    state.add_conditional_state(
        'SCHEDULED',
        state.CONFIRMED,
        lambda proposal: proposal.session is not None and proposal.session.
        scheduled,
        label=('scheduled', __("Confirmed & Scheduled")),
    )

    @with_roles(call={'creator'})
    @state.transition(
        state.AWAITING_DETAILS,
        state.DRAFT,
        title=__("Draft"),
        message=__("This proposal has been withdrawn"),
        type='danger',
    )
    def withdraw(self):
        pass

    @with_roles(call={'creator'})
    @state.transition(
        state.DRAFT,
        state.SUBMITTED,
        title=__("Submit"),
        message=__("This proposal has been submitted"),
        type='success',
    )
    def submit(self):
        pass

    # TODO: remove project_editor once ProposalMembership UI
    # has been implemented
    @with_roles(call={'project_editor', 'reviewer'})
    @state.transition(
        state.UNDO_TO_SUBMITTED,
        state.SUBMITTED,
        title=__("Send Back to Submitted"),
        message=__("This proposal has been submitted"),
        type='danger',
    )
    def undo_to_submitted(self):
        pass

    @with_roles(call={'project_editor', 'reviewer'})
    @state.transition(
        state.CONFIRMABLE,
        state.CONFIRMED,
        title=__("Confirm"),
        message=__("This proposal has been confirmed"),
        type='success',
    )
    def confirm(self):
        pass

    @with_roles(call={'project_editor', 'reviewer'})
    @state.transition(
        state.CONFIRMED,
        state.SUBMITTED,
        title=__("Unconfirm"),
        message=__("This proposal is no longer confirmed"),
        type='danger',
    )
    def unconfirm(self):
        pass

    @with_roles(call={'project_editor', 'reviewer'})
    @state.transition(
        state.WAITLISTABLE,
        state.WAITLISTED,
        title=__("Waitlist"),
        message=__("This proposal has been waitlisted"),
        type='primary',
    )
    def waitlist(self):
        pass

    @with_roles(call={'project_editor', 'reviewer'})
    @state.transition(
        state.REJECTABLE,
        state.REJECTED,
        title=__("Reject"),
        message=__("This proposal has been rejected"),
        type='danger',
    )
    def reject(self):
        pass

    @with_roles(call={'creator'})
    @state.transition(
        state.CANCELLABLE,
        state.CANCELLED,
        title=__("Cancel"),
        message=__("This proposal has been cancelled"),
        type='danger',
    )
    def cancel(self):
        pass

    @with_roles(call={'creator'})
    @state.transition(
        state.CANCELLED,
        state.SUBMITTED,
        title=__("Undo cancel"),
        message=__("This proposal's cancellation has been reversed"),
        type='success',
    )
    def undo_cancel(self):
        pass

    @with_roles(call={'project_editor', 'reviewer'})
    @state.transition(
        state.SUBMITTED,
        state.AWAITING_DETAILS,
        title=__("Awaiting details"),
        message=__("Awaiting details for this proposal"),
        type='primary',
    )
    def awaiting_details(self):
        pass

    @with_roles(call={'project_editor', 'reviewer'})
    @state.transition(
        state.EVALUATEABLE,
        state.UNDER_EVALUATION,
        title=__("Under evaluation"),
        message=__("This proposal has been put under evaluation"),
        type='success',
    )
    def under_evaluation(self):
        pass

    @with_roles(call={'creator'})
    @state.transition(
        state.DELETABLE,
        state.DELETED,
        title=__("Delete"),
        message=__("This proposal has been deleted"),
        type='danger',
    )
    def delete(self):
        pass

    # These 3 transitions are not in the editorial workflow anymore - Feb 23 2018

    # @with_roles(call={'project_editor'})
    # @state.transition(state.SUBMITTED, state.SHORTLISTED, title=__("Shortlist"), message=__("This proposal has been shortlisted"), type='success')
    # def shortlist(self):
    #     pass

    # @with_roles(call={'project_editor'})
    # @state.transition(state.SHORLISTABLE, state.SHORTLISTED_FOR_REHEARSAL, title=__("Shortlist for rehearsal"), message=__("This proposal has been shortlisted for rehearsal"), type='success')
    # def shortlist_for_rehearsal(self):
    #     pass

    # @with_roles(call={'project_editor'})
    # @state.transition(state.SHORTLISTED_FOR_REHEARSAL, state.REHEARSAL, title=__("Rehearsal ongoing"), message=__("Rehearsal is now ongoing for this proposal"), type='success')
    # def rehearsal_ongoing(self):
    #     pass

    @with_roles(call={'project_editor', 'reviewer'})
    def move_to(self, project):
        """
        Move to a new project and reset the url_id
        """
        self.project = project
        self.url_id = None
        self.make_id()

    @with_roles(call={'project_editor', 'reviewer'})
    def transfer_to(self, user):
        """
        Transfer the proposal to a new user and speaker
        """
        self.speaker = user

    @property
    def owner(self):
        return self.speaker or self.user

    @property
    def speaking(self):
        return self.speaker == self.user

    @speaking.setter
    def speaking(self, value):
        if value:
            self.speaker = self.user
        else:
            if self.speaker == self.user:
                self.speaker = None  # Reset only if it's currently set to user

    @hybrid_property
    def datetime(self):
        return self.created_at  # Until proposals have a workflow-driven datetime

    @cached_property
    def has_outstation_speaker(self):
        """
        Returns True iff the location can be geocoded and is found to be different
        compared to the project's location.
        """
        geonameid = geonameid_from_location(self.location)
        return bool(geonameid) and self.project.location_geonameid.isdisjoint(
            geonameid)

    def getnext(self):
        return (Proposal.query.filter(Proposal.project == self.project).filter(
            Proposal.id != self.id).filter(
                Proposal._state == self.state.value).filter(
                    Proposal.created_at < self.created_at).order_by(
                        db.desc('created_at')).first())

    def getprev(self):
        return (Proposal.query.filter(Proposal.project == self.project).filter(
            Proposal.id != self.id).filter(
                Proposal._state == self.state.value).filter(
                    Proposal.created_at > self.created_at).order_by(
                        'created_at').first())

    def votes_count(self):
        return len(self.voteset.votes)

    def permissions(self, user, inherited=None):
        perms = super(Proposal, self).permissions(user, inherited)
        if user is not None:
            perms.update(('vote_proposal', 'new_comment', 'vote_comment'))
            if user == self.owner:
                perms.update((
                    'view-proposal',
                    'edit_proposal',
                    'delete-proposal',  # FIXME: Prevent deletion of confirmed proposals
                    'submit-proposal',  # For workflows, to confirm the form is ready for submission (from draft state)
                    'transfer-proposal',
                ))
                if self.speaker != self.user:
                    perms.add('decline-proposal')  # Decline speaking
        return perms

    def roles_for(self, actor=None, anchors=()):
        roles = super(Proposal, self).roles_for(actor, anchors)
        if self.state.DRAFT:
            if 'reader' in roles:
                # https://github.com/hasgeek/funnel/pull/220#discussion_r168724439
                roles.remove('reader')
        else:
            roles.add('reader')

        # remove the owner check after proposal membership is implemented
        if self.owner == actor or roles.has_any(
            ('project_participant', 'presenter', 'reviewer')):
            roles.add('commenter')

        return roles
Example #11
0
class Profile(UuidMixin, BaseMixin, db.Model):
    """
    Profiles are the public-facing pages for the User and Organization models.
    """

    __tablename__ = 'profile'
    __uuid_primary_key__ = False
    # length limit 63 to fit DNS label limit
    __name_length__ = 63
    reserved_names = RESERVED_NAMES

    #: The "username" assigned to a user or organization.
    #: Length limit 63 to fit DNS label limit
    name = db.Column(
        db.Unicode(__name_length__),
        db.CheckConstraint("name <> ''"),
        nullable=False,
        unique=True,
    )
    # Only one of the following three may be set:
    #: User that owns this name (limit one per user)
    user_id = db.Column(None,
                        db.ForeignKey('user.id', ondelete='SET NULL'),
                        unique=True,
                        nullable=True)

    # No `cascade='delete-orphan'` in User and Organization backrefs as profiles cannot
    # be trivially deleted

    user = with_roles(
        db.relationship(
            'User',
            lazy='joined',
            backref=db.backref('profile',
                               lazy='joined',
                               uselist=False,
                               cascade='all'),
        ),
        grants={'owner'},
    )
    #: Organization that owns this name (limit one per organization)
    organization_id = db.Column(
        None,
        db.ForeignKey('organization.id', ondelete='SET NULL'),
        unique=True,
        nullable=True,
    )
    organization = db.relationship(
        'Organization',
        lazy='joined',
        backref=db.backref('profile',
                           lazy='joined',
                           uselist=False,
                           cascade='all'),
    )
    #: Reserved profile (not assigned to any party)
    reserved = db.Column(db.Boolean, nullable=False, default=False, index=True)

    _state = db.Column(
        'state',
        db.Integer,
        StateManager.check_constraint('state', PROFILE_STATE),
        nullable=False,
        default=PROFILE_STATE.AUTO,
    )
    state = StateManager('_state',
                         PROFILE_STATE,
                         doc="Current state of the profile")

    description = MarkdownColumn('description', default='', nullable=False)
    website = db.Column(UrlType, nullable=True)
    logo_url = db.Column(UrlType, nullable=True)
    banner_image_url = db.Column(UrlType, nullable=True)
    #: Legacy profiles are available via funnelapp, non-legacy in the main app
    legacy = db.Column(db.Boolean, default=False, nullable=False)

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

    __table_args__ = (
        db.CheckConstraint(
            db.case([(user_id.isnot(None), 1)], else_=0) +
            db.case([(organization_id.isnot(None), 1)], else_=0) +
            db.case([(reserved.is_(True), 1)], else_=0) == 1,
            name='profile_owner_check',
        ),
        db.Index(
            'ix_profile_name_lower',
            db.func.lower(name).label('name_lower'),
            unique=True,
            postgresql_ops={'name_lower': 'varchar_pattern_ops'},
        ),
        db.Index('ix_profile_search_vector',
                 'search_vector',
                 postgresql_using='gin'),
    )

    __roles__ = {
        'all': {
            'read': {
                'urls',
                'uuid_b58',
                'name',
                'title',
                'description',
                'website',
                'logo_url',
                'user',
                'organization',
                'banner_image_url',
                'is_organization_profile',
                'is_user_profile',
                'owner',
            },
            'call': {'url_for', 'features', 'forms'},
        }
    }

    __datasets__ = {
        'primary': {
            'urls',
            'uuid_b58',
            'name',
            'title',
            'description',
            'logo_url',
            'website',
            'user',
            'organization',
            'owner',
        },
        'related':
        {'urls', 'uuid_b58', 'name', 'title', 'description', 'logo_url'},
    }

    def __repr__(self):
        return f'<Profile "{self.name}">'

    @property
    def owner(self):
        return self.user or self.organization

    @owner.setter
    def owner(self, value):
        if isinstance(value, User):
            self.user = value
            self.organization = None
        elif isinstance(value, Organization):
            self.user = None
            self.organization = value
        else:
            raise ValueError(value)
        self.reserved = False

    @hybrid_property
    def is_user_profile(self):
        return self.user_id is not None

    @is_user_profile.expression
    def is_user_profile(cls):  # NOQA: N805
        return cls.user_id.isnot(None)

    @hybrid_property
    def is_organization_profile(self):
        return self.organization_id is not None

    @is_organization_profile.expression
    def is_organization_profile(cls):  # NOQA: N805
        return cls.organization_id.isnot(None)

    @with_roles(read={'all'})
    @property
    def is_public(self):
        return bool(self.state.PUBLIC)

    @hybrid_property
    def title(self):
        if self.user:
            return self.user.fullname
        elif self.organization:
            return self.organization.title
        else:
            return ''

    @title.setter
    def title(self, value):
        if self.user:
            self.user.fullname = value
        elif self.organization:
            self.organization.title = value
        else:
            raise ValueError("Reserved profiles do not have titles")

    @title.expression
    def title(cls):  # NOQA: N805
        return db.case(
            [
                (
                    # if...
                    cls.user_id.isnot(None),
                    # then...
                    db.select([User.fullname]).where(cls.user_id == User.id
                                                     ).as_scalar(),
                ),
                (
                    # elif...
                    cls.organization_id.isnot(None),
                    # then...
                    db.select([Organization.title]).where(
                        cls.organization_id == Organization.id).as_scalar(),
                ),
            ],
            else_='',
        )

    def roles_for(self, actor, anchors=()):
        if self.owner:
            roles = self.owner.roles_for(actor, anchors)
        else:
            roles = super().roles_for(actor, anchors)
        if self.state.PUBLIC:
            roles.add('reader')
        return roles

    @classmethod
    def get(cls, name):
        return cls.query.filter(
            db.func.lower(Profile.name) == db.func.lower(name)).one_or_none()

    @classmethod
    def validate_name_candidate(cls, name):
        """
        Check if a name is available, returning one of several error codes, or None if
        all is okay:

        * ``blank``: No name supplied
        * ``invalid``: Invalid characters in name
        * ``long``: Name is longer than allowed size
        * ``reserved``: Name is reserved
        * ``user``: Name is assigned to a user
        * ``org``: Name is assigned to an organization
        """
        if not name:
            return 'blank'
        elif name.lower() in cls.reserved_names:
            return 'reserved'
        elif not valid_username(name):
            return 'invalid'
        elif len(name) > cls.__name_length__:
            return 'long'
        existing = (cls.query.filter(
            db.func.lower(cls.name) == db.func.lower(name)).options(
                db.load_only(cls.id, cls.uuid, cls.user_id,
                             cls.organization_id, cls.reserved)).one_or_none())
        if existing:
            if existing.reserved:
                return 'reserved'
            elif existing.user_id:
                return 'user'
            elif existing.organization_id:
                return 'org'

    @classmethod
    def is_available_name(cls, name):
        return cls.validate_name_candidate(name) is None

    @db.validates('name')
    def validate_name(self, key, value):
        if value.lower() in self.reserved_names or not valid_username(value):
            raise ValueError("Invalid account name: " + value)
        # We don't check for existence in the db since this validator only
        # checks for valid syntax. To confirm the name is actually available,
        # the caller must call :meth:`is_available_name` or attempt to commit
        # to the db and catch IntegrityError.
        return value

    @classmethod
    def migrate_user(cls, old_user, new_user):
        if old_user.profile and not new_user.profile:
            # New user doesn't have a profile. Simply transfer ownership.
            new_user.profile = old_user.profile
        elif old_user.profile and new_user.profile:
            # Both have profiles. Move everything that refers to old profile
            done = do_migrate_instances(old_user.profile, new_user.profile,
                                        'migrate_profile')
            if done:
                db.session.delete(old_user.profile)

    @property
    def teams(self):
        if self.organization:
            return self.organization.teams
        else:
            return []

    def permissions(self, user, inherited=None):
        perms = super(Profile, self).permissions(user, inherited)
        perms.add('view')
        if 'admin' in self.roles_for(user):
            perms.add('edit-profile')
            perms.add('new_project')
            perms.add('delete-project')
            perms.add('edit_project')
        return perms

    @with_roles(call={'owner'})
    @state.transition(None, state.PUBLIC, title=__("Make public"))
    def make_public(self):
        pass

    @with_roles(call={'owner'})
    @state.transition(None, state.PRIVATE, title=__("Make private"))
    def make_private(self):
        pass
Example #12
0
class EmailAddress(BaseMixin, db.Model):
    """
    Represents an email address as a standalone entity, with associated metadata.

    Prior to this model, email addresses were regarded as properties of other models.
    Specifically: Proposal.email, Participant.email, User.emails and User.emailclaims,
    the latter two lists populated using the UserEmail and UserEmailClaim join models.
    This subordination made it difficult to track ownership of an email address or its
    reachability (active, bouncing, etc). Having EmailAddress as a standalone model
    (with incoming foreign keys) provides some sanity:

    1. Email addresses are stored with a hash, and always looked up using the hash. This
       allows the address to be forgotten while preserving the record for metadata.
    2. A forgotten address's record can be restored given the correct email address.
    3. Addresses can be automatically forgotten when they are no longer referenced. This
       ability is implemented using the :attr:`emailaddress_refcount_dropping` signal
       and supporting code in ``views/helpers.py`` and ``jobs/jobs.py``.
    4. If there is abuse, an email address can be comprehensively blocked using its
       canonical representation, which prevents the address from being used even via
       its ``+sub-address`` variations.
    5. Via :class:`EmailAddressMixin`, the UserEmail model can establish ownership of
       an email address on behalf of a user, placing an automatic block on its use by
       other users. This mechanism is not limited to users. A future OrgEmail link can
       establish ownership on behalf of an organization.
    6. Upcoming: column-level encryption of the email column, securing SQL dumps.

    New email addresses must be added using the :meth:`add` or :meth:`add_for`
    classmethods, depending on whether the email address is linked to an owner or not.
    """

    __tablename__ = 'email_address'

    #: Backrefs to this model from other models, populated by :class:`EmailAddressMixin`
    __backrefs__ = set()
    #: These backrefs claim exclusive use of the email address for their linked owner.
    #: See :class:`EmailAddressMixin` for implementation detail
    __exclusive_backrefs__ = set()

    #: The email address, centrepiece of this model. Case preserving.
    #: Validated by the :func:`_validate_email` event handler
    email = db.Column(db.Unicode, nullable=True)
    #: The domain of the email, stored for quick lookup of related addresses
    #: Read-only, accessible via the :property:`domain` property
    _domain = db.Column('domain', db.Unicode, nullable=True, index=True)

    # email_normalized is defined below

    #: BLAKE2b 160-bit hash of :property:`email_normalized`. Kept permanently even if email
    #: is removed. SQLAlchemy type LargeBinary maps to PostgreSQL BYTEA. Despite the
    #: name, we're only storing 20 bytes
    blake2b160 = immutable(db.Column(db.LargeBinary, nullable=False, unique=True))

    #: BLAKE2b 160-bit hash of :property:`email_canonical`. Kept permanently for blocked
    #: email detection. Indexed but does not use a unique constraint because a+b@tld and
    #: a+c@tld are both a@tld canonically.
    blake2b160_canonical = immutable(
        db.Column(db.LargeBinary, nullable=False, index=True)
    )

    #: Does this email address work? Records last known delivery state
    _delivery_state = db.Column(
        'delivery_state',
        db.Integer,
        StateManager.check_constraint(
            'delivery_state',
            EMAIL_DELIVERY_STATE,
            name='email_address_delivery_state_check',
        ),
        nullable=False,
        default=EMAIL_DELIVERY_STATE.UNKNOWN,
    )
    delivery_state = StateManager(
        '_delivery_state',
        EMAIL_DELIVERY_STATE,
        doc="Last known delivery state of this email address",
    )
    #: Timestamp of last known delivery state
    delivery_state_at = db.Column(
        db.TIMESTAMP(timezone=True), nullable=False, default=db.func.utcnow()
    )
    #: Timestamp of last known recipient activity resulting from sent mail
    active_at = db.Column(db.TIMESTAMP(timezone=True), nullable=True)

    #: Is this email address blocked from being used? If so, :attr:`email` should be
    #: null. Blocks apply to the canonical address (without the +sub-address variation),
    #: so a test for whether an address is blocked should use blake2b160_canonical to
    #: load the record. Other records with the same canonical hash _may_ exist without
    #: setting the flag due to a lack of database-side enforcement
    _is_blocked = db.Column('is_blocked', db.Boolean, nullable=False, default=False)

    __table_args__ = (
        # `domain` must be lowercase always. Note that Python `.lower()` is not
        # guaranteed to produce identical output to SQL `lower()` with non-ASCII
        # characters. It is only safe to use here because domain names are always ASCII
        db.CheckConstraint(
            _domain == db.func.lower(_domain), 'email_address_domain_check'
        ),
        # If `is_blocked` is True, `email` and `domain` must be None
        db.CheckConstraint(
            db.or_(
                _is_blocked.isnot(True),
                db.and_(_is_blocked.is_(True), email.is_(None), _domain.is_(None)),
            ),
            'email_address_email_is_blocked_check',
        ),
        # `email` and `domain` must be None, or `email.endswith(domain)` must be True.
        # However, the endswith constraint is relaxed with IDN domains, as there is no
        # easy way to do an IDN match in Postgres without an extension.
        # `_` and `%` must be escaped as they are wildcards to the LIKE/ILIKE operator
        db.CheckConstraint(
            db.or_(
                # email and domain must both be non-null, or
                db.and_(email.is_(None), _domain.is_(None)),
                # domain must be an IDN, or
                email.op('SIMILAR TO')('(xn--|%.xn--)%'),
                # domain is ASCII (typical case) and must be the suffix of email
                email.ilike(
                    '%'
                    + db.func.replace(db.func.replace(_domain, '_', r'\_'), '%', r'\%')
                ),
            ),
            'email_address_email_domain_check',
        ),
    )

    @hybrid_property
    def is_blocked(self) -> bool:
        """
        Read-only flag indicating this email address is blocked from use. To set this
        flag, call :classmethod:`mark_blocked` using the email address. The flag will be
        simultaneously set on all matching instances.
        """
        return self._is_blocked

    @hybrid_property
    def domain(self) -> Optional[str]:
        """The domain of the email, stored for quick lookup of related addresses."""
        return self._domain

    # This should not use `cached_property` as email is partially mutable
    @property
    def email_normalized(self) -> Optional[str]:
        """
        Normalized representation of the email address, for hashing.
        """
        return email_normalized(self.email) if self.email else None

    # This should not use `cached_property` as email is partially mutable
    @property
    def email_canonical(self) -> Optional[str]:
        """
        Email address with the ``+sub-address`` portion of the mailbox removed.

        This is only used to identify and prevent re-use of blocked email addresses
        using the ``+sub-address`` method. Regular use does allow the ``+`` symbol.
        Special handling for the gmail.com domain also strips periods from the
        canonical representation. This makes the representation invalid for emailing.

        The canonical representation is not stored, but its blake2b160 representation is
        """
        return canonical_email_representation(self.email)[0] if self.email else None

    @with_roles(read={'all'})
    @cached_property
    def email_hash(self) -> str:
        """Public identifier string for email address, usable in URLs."""
        return base58.b58encode(self.blake2b160).decode()

    # Compatibility name for notifications framework
    transport_hash = email_hash

    @with_roles(call={'all'})
    def md5(self) -> Optional[str]:
        """MD5 hash of :property:`email_normalized`, for legacy use only."""
        return (
            hashlib.md5(  # NOQA: S303 # skipcq: PTC-W1003 # nosec
                self.email_normalized.encode('utf-8')
            ).hexdigest()
            if self.email_normalized
            else None
        )

    def __str__(self) -> str:
        """Cast email address into a string."""
        return self.email or ''

    def __repr__(self) -> str:
        """Debugging representation of the email address."""
        return f'EmailAddress({self.email!r})'

    def __init__(self, email: str) -> None:
        if not isinstance(email, str):
            raise ValueError("A string email address is required")
        # Set the hash first so the email column validator passes. Both hash columns
        # are immutable once set, so there are no content validators for them.
        self.blake2b160 = email_blake2b160_hash(email)
        self.email = email
        self.blake2b160_canonical = email_blake2b160_hash(self.email_canonical)

    def is_available_for(self, owner: Any):
        """Return True if this EmailAddress is available for the given owner."""
        for backref_name in self.__exclusive_backrefs__:
            for related_obj in getattr(self, backref_name):
                curr_owner = getattr(related_obj, related_obj.__email_for__)
                if curr_owner is not None and curr_owner != owner:
                    return False
        return True

    @delivery_state.transition(None, delivery_state.SENT)
    def mark_sent(self) -> None:
        """Record fact of an email message being sent to this address."""
        self.delivery_state_at = db.func.utcnow()

    def mark_active(self) -> None:
        """Record timestamp of recipient activity."""
        self.active_at = db.func.utcnow()

    @delivery_state.transition(None, delivery_state.SOFT_FAIL)
    def mark_soft_fail(self) -> None:
        """Record fact of a soft fail to this email address."""
        self.delivery_state_at = db.func.utcnow()

    @delivery_state.transition(None, delivery_state.HARD_FAIL)
    def mark_hard_fail(self) -> None:
        """Record fact of a hard fail to this email address."""
        self.delivery_state_at = db.func.utcnow()

    def refcount(self) -> int:
        """Returns count of references to this EmailAddress instance"""
        # obj.email_address_reference_is_active is a bool, but int(bool) is 0 or 1
        return sum(
            sum(
                obj.email_address_reference_is_active
                for obj in getattr(self, backref_name)
            )
            for backref_name in self.__backrefs__
        )

    @classmethod
    def mark_blocked(cls, email: str) -> None:
        """
        Mark email address as blocked.

        Looks up all existing instances of EmailAddress with the same canonical
        representation and amends them to forget the email address and set the
        :attr:`is_blocked` flag.
        """
        for obj in cls.get_canonical(email, is_blocked=False).all():
            obj.email = None
            obj._is_blocked = True

    @classmethod
    def get_filter(
        cls,
        email: Optional[str] = None,
        blake2b160: Optional[bytes] = None,
        email_hash: Optional[str] = None,
    ):
        """
        Get an filter condition for retriving an EmailAddress.

        Accepts an email address or a blake2b160 hash in either bytes or base58 form.
        Internally converts all lookups to a bytes-based hash lookup. Returns an
        expression suitable for use as a query filter.
        """
        require_one_of(email=email, blake2b160=blake2b160, email_hash=email_hash)
        if email:
            if not cls.is_valid_email_address(email):
                return
            blake2b160 = email_blake2b160_hash(email)
        elif email_hash:
            blake2b160 = base58.b58decode(email_hash)

        return cls.blake2b160 == blake2b160

    @classmethod
    def get(
        cls,
        email: Optional[str] = None,
        blake2b160: Optional[bytes] = None,
        email_hash: Optional[str] = None,
    ) -> Optional[EmailAddress]:
        """
        Get an :class:`EmailAddress` instance by email address or its hash.

        Internally converts an email-based lookup into a hash-based lookup.
        """
        return cls.query.filter(
            cls.get_filter(email, blake2b160, email_hash)
        ).one_or_none()

    @classmethod
    def get_canonical(
        cls, email: str, is_blocked: Optional[bool] = None
    ) -> Iterable[EmailAddress]:
        """
        Get :class:`EmailAddress` instances matching the canonical representation.

        Optionally filtered by the :attr:`is_blocked` flag.
        """
        hashes = [
            email_blake2b160_hash(result)
            for result in canonical_email_representation(email)
        ]
        query = cls.query.filter(cls.blake2b160_canonical.in_(hashes))
        if is_blocked is not None:
            query = query.filter_by(_is_blocked=is_blocked)
        return query

    @classmethod
    def _get_existing(cls, email: str) -> Optional[EmailAddress]:
        """
        Internal method used by :meth:`add`, :meth:`add_for` and :meth:`validate_for`.
        """
        if not cls.is_valid_email_address(email):
            return
        if cls.get_canonical(email, is_blocked=True).notempty():
            raise EmailAddressBlockedError("Email address is blocked")
        return EmailAddress.get(email)

    @classmethod
    def add(cls, email: str) -> EmailAddress:
        """
        Create a new :class:`EmailAddress` after validation.

        Raises an exception if the address is blocked from use, or the email address
        is syntactically invalid.
        """
        existing = cls._get_existing(email)
        if existing:
            # Restore the email column if it's not present. Do not modify it otherwise
            if not existing.email:
                existing.email = email
            return existing
        new_email = EmailAddress(email)
        db.session.add(new_email)
        return new_email

    @classmethod
    def add_for(cls, owner: Optional[Any], email: str) -> EmailAddress:
        """
        Create a new :class:`EmailAddress` after validation.

        Unlike :meth:`add`, this one requires the email address to not be in an
        exclusive relationship with another owner.
        """
        existing = cls._get_existing(email)
        if existing:
            if not existing.is_available_for(owner):
                raise EmailAddressInUseError("This email address is in use")
            # No exclusive lock found? Let it be used then
            existing.email = email
            return existing
        new_email = EmailAddress(email)
        db.session.add(new_email)
        return new_email

    @classmethod
    def validate_for(
        cls,
        owner: Optional[Any],
        email: str,
        check_dns: bool = False,
        new: bool = False,
    ) -> Union[bool, str]:
        """
        Validate whether the email address is available to the given owner.

        Returns False if the address is blocked or in use by another owner, True if
        available without issues, or a string value indicating the concern:

        1. 'nomx': Email address is available, but has no MX records
        2. 'not_new': Email address is already attached to owner (if `new` is True)
        3. 'soft_fail': Known to be soft bouncing, requiring a warning message
        4. 'hard_fail': Known to be hard bouncing, usually a validation failure
        5. 'invalid': Available, but failed syntax validation

        :param owner: Proposed owner of this email address (may be None)
        :param str email: Email address to validate
        :param bool check_dns: Check for MX records for a new email address
        :param bool new: Fail validation if email address is already in use
        """
        try:
            existing = cls._get_existing(email)
        except EmailAddressBlockedError:
            return False
        if not existing:
            diagnosis = cls.is_valid_email_address(
                email, check_dns=check_dns, diagnose=True
            )
            if diagnosis is True:
                # No problems
                return True
            if diagnosis and diagnosis.diagnosis_type == 'NO_MX_RECORD':
                return 'nomx'
            return 'invalid'
        # There's an existing? Is it available for this owner?
        if not existing.is_available_for(owner):
            return False

        # Any other concerns?
        if new:
            return 'not_new'
        elif existing.delivery_state.SOFT_FAIL:
            return 'soft_fail'
        elif existing.delivery_state.HARD_FAIL:
            return 'hard_fail'
        return True

    @staticmethod
    def is_valid_email_address(email: str, check_dns=False, diagnose=False) -> bool:
        """
        Return True if given email address is syntactically valid.

        This implementation will refuse to accept unusual elements such as quoted
        strings, as they are unlikely to appear in real-world use.

        :param bool check_dns: Optionally, check for existence of MX records
        :param bool diagnose: In case of errors only, return the diagnosis
        """
        if email:
            result = is_email(email, check_dns=check_dns, diagnose=True)
            if result.diagnosis_type in ('VALID', 'NO_NAMESERVERS', 'DNS_TIMEDOUT'):
                return True
            return result if diagnose else False
        return False