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)
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)
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()])]
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
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()
class Project(UuidMixin, BaseScopedNameMixin, db.Model): __tablename__ = 'project' reserved_names = RESERVED_NAMES user_id = db.Column(None, db.ForeignKey('user.id'), nullable=False) user = db.relationship( User, primaryjoin=user_id == User.id, backref=db.backref('projects', cascade='all'), ) profile_id = db.Column(None, db.ForeignKey('profile.id'), nullable=False) profile = with_roles( db.relationship('Profile', backref=db.backref('projects', cascade='all', lazy='dynamic')), read={'all'}, # If profile grants an 'admin' role, make it 'profile_admin' here grants_via={None: { 'admin': 'profile_admin' }}, # `profile` only appears in the 'primary' dataset. It must not be included in # 'related' or 'without_parent' as it is the parent datasets={'primary'}, ) parent = db.synonym('profile') tagline = with_roles( db.Column(db.Unicode(250), nullable=False), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) description = with_roles(MarkdownColumn('description', default='', nullable=False), read={'all'}) instructions = with_roles(MarkdownColumn('instructions', default='', nullable=True), read={'all'}) location = with_roles( db.Column(db.Unicode(50), default='', nullable=True), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) parsed_location = db.Column(JsonDict, nullable=False, server_default='{}') website = with_roles( db.Column(UrlType, nullable=True), read={'all'}, datasets={'primary', 'without_parent'}, ) timezone = with_roles( db.Column(TimezoneType(backend='pytz'), nullable=False, default=utc), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) _state = db.Column( 'state', db.Integer, StateManager.check_constraint('state', PROJECT_STATE), default=PROJECT_STATE.DRAFT, nullable=False, ) state = with_roles(StateManager('_state', PROJECT_STATE, doc="Project state"), call={'all'}) _cfp_state = db.Column( 'cfp_state', db.Integer, StateManager.check_constraint('cfp_state', CFP_STATE), default=CFP_STATE.NONE, nullable=False, ) cfp_state = with_roles(StateManager('_cfp_state', CFP_STATE, doc="CfP state"), call={'all'}) _schedule_state = db.Column( 'schedule_state', db.Integer, StateManager.check_constraint('schedule_state', SCHEDULE_STATE), default=SCHEDULE_STATE.DRAFT, nullable=False, ) schedule_state = with_roles( StateManager('_schedule_state', SCHEDULE_STATE, doc="Schedule state"), call={'all'}, ) cfp_start_at = db.Column(db.TIMESTAMP(timezone=True), nullable=True) cfp_end_at = db.Column(db.TIMESTAMP(timezone=True), nullable=True) bg_image = with_roles( db.Column(UrlType, nullable=True), read={'all'}, datasets={'primary', 'without_parent'}, ) allow_rsvp = db.Column(db.Boolean, default=False, nullable=False) buy_tickets_url = db.Column(UrlType, nullable=True) banner_video_url = with_roles( db.Column(UrlType, nullable=True), read={'all'}, datasets={'primary', 'without_parent'}, ) boxoffice_data = with_roles( db.Column(JsonDict, nullable=False, server_default='{}'), # This is an attribute, but we deliberately use `call` instead of `read` to # block this from dictionary enumeration. FIXME: Break up this dictionary into # individual columns with `all` access for ticket embed id and `concierge` # access for ticket sync access token. call={'all'}, ) hasjob_embed_url = with_roles(db.Column(UrlType, nullable=True), read={'all'}) hasjob_embed_limit = with_roles(db.Column(db.Integer, default=8), read={'all'}) voteset_id = db.Column(None, db.ForeignKey('voteset.id'), nullable=False) voteset = db.relationship(Voteset, uselist=False) commentset_id = db.Column(None, db.ForeignKey('commentset.id'), nullable=False) commentset = db.relationship( Commentset, uselist=False, cascade='all', single_parent=True, back_populates='project', ) parent_id = db.Column(None, db.ForeignKey('project.id', ondelete='SET NULL'), nullable=True) parent_project = db.relationship('Project', remote_side='Project.id', backref='subprojects') #: Featured project flag. This can only be set by website editors, not #: project editors or profile admins. featured = with_roles( db.Column(db.Boolean, default=False, nullable=False), read={'all'}, write={'site_editor'}, datasets={'primary', 'without_parent'}, ) search_vector = db.deferred( db.Column( TSVectorType( 'name', 'title', 'description_text', 'instructions_text', 'location', weights={ 'name': 'A', 'title': 'A', 'description_text': 'B', 'instructions_text': 'B', 'location': 'C', }, regconfig='english', hltext=lambda: db.func.concat_ws( visual_field_delimiter, Project.title, Project.location, Project.description_html, Project.instructions_html, ), ), nullable=False, )) livestream_urls = with_roles( db.Column(db.ARRAY(db.UnicodeText, dimensions=1), server_default='{}'), read={'all'}, datasets={'primary', 'without_parent'}, ) venues = with_roles( db.relationship( 'Venue', cascade='all', order_by='Venue.seq', collection_class=ordering_list('seq', count_from=1), ), read={'all'}, ) labels = db.relationship( 'Label', cascade='all', primaryjoin= 'and_(Label.project_id == Project.id, Label.main_label_id == None, Label._archived == False)', order_by='Label.seq', collection_class=ordering_list('seq', count_from=1), ) all_labels = db.relationship('Label', lazy='dynamic') __table_args__ = ( db.UniqueConstraint('profile_id', 'name'), db.Index('ix_project_search_vector', 'search_vector', postgresql_using='gin'), ) __roles__ = { 'all': { 'read': { 'absolute_url', # From UrlForMixin 'name', # From BaseScopedNameMixin 'short_title', # From BaseScopedNameMixin 'title', # From BaseScopedNameMixin 'urls', # From UrlForMixin }, 'call': { 'features', # From RegistryMixin 'forms', # From RegistryMixin 'url_for', # From UrlForMixin 'view_for', # From UrlForMixin 'views', # From RegistryMixin }, }, } __datasets__ = { 'primary': { 'absolute_url', # From UrlForMixin 'name', # From BaseScopedNameMixin 'title', # From BaseScopedNameMixin 'urls', # From UrlForMixin }, 'without_parent': { 'absolute_url', # From UrlForMixin 'name', # From BaseScopedNameMixin 'title', # From BaseScopedNameMixin }, 'related': { 'absolute_url', # From UrlForMixin 'name', # From BaseScopedNameMixin 'title', # From BaseScopedNameMixin }, } schedule_state.add_conditional_state( 'PAST', schedule_state.PUBLISHED, lambda project: project.schedule_end_at is not None and utcnow() >= project.schedule_end_at, lambda project: db.func.utcnow() >= project.schedule_end_at, label=('past', __("Past")), ) schedule_state.add_conditional_state( 'LIVE', schedule_state.PUBLISHED, lambda project: (project.schedule_start_at is not None and project.schedule_start_at <= utcnow() < project.schedule_end_at), lambda project: db.and_( project.schedule_start_at <= db.func.utcnow(), db.func.utcnow() < project.schedule_end_at, ), label=('live', __("Live")), ) schedule_state.add_conditional_state( 'UPCOMING', schedule_state.PUBLISHED, lambda project: project.schedule_start_at is not None and utcnow() < project.schedule_start_at, lambda project: db.func.utcnow() < project.schedule_start_at, label=('upcoming', __("Upcoming")), ) schedule_state.add_conditional_state( 'PUBLISHED_WITHOUT_SESSIONS', schedule_state.PUBLISHED, lambda project: project.schedule_start_at is None, lambda project: project.schedule_start_at.is_(None), label=('published_without_sessions', __("Published without sessions")), ) cfp_state.add_conditional_state( 'HAS_PROPOSALS', cfp_state.EXISTS, lambda project: db.session.query(project.proposals.exists()).scalar(), label=('has_proposals', __("Has proposals")), ) cfp_state.add_conditional_state( 'HAS_SESSIONS', cfp_state.EXISTS, lambda project: db.session.query(project.sessions.exists()).scalar(), label=('has_sessions', __("Has sessions")), ) cfp_state.add_conditional_state( 'PRIVATE_DRAFT', cfp_state.NONE, lambda project: project.instructions_html != '', lambda project: db.and_(project.instructions_html.isnot(None), project. instructions_html != ''), label=('private_draft', __("Private draft")), ) cfp_state.add_conditional_state( 'DRAFT', cfp_state.PUBLIC, lambda project: project.cfp_start_at is None, lambda project: project.cfp_start_at.is_(None), label=('draft', __("Draft")), ) cfp_state.add_conditional_state( 'UPCOMING', cfp_state.PUBLIC, lambda project: project.cfp_start_at is not None and utcnow() < project .cfp_start_at, lambda project: db.and_(project.cfp_start_at.isnot(None), db.func.utcnow() < project.cfp_start_at), label=('upcoming', __("Upcoming")), ) cfp_state.add_conditional_state( 'OPEN', cfp_state.PUBLIC, lambda project: project.cfp_start_at is not None and project. cfp_start_at <= utcnow() and (project.cfp_end_at is None or (utcnow() < project.cfp_end_at)), lambda project: db.and_( project.cfp_start_at.isnot(None), project.cfp_start_at <= db.func.utcnow(), db.or_(project.cfp_end_at.is_(None), db.func.utcnow() < project.cfp_end_at), ), label=('open', __("Open")), ) cfp_state.add_conditional_state( 'EXPIRED', cfp_state.PUBLIC, lambda project: project.cfp_end_at is not None and utcnow() >= project. cfp_end_at, lambda project: db.and_(project.cfp_end_at.isnot(None), db.func.utcnow() >= project.cfp_end_at), label=('expired', __("Expired")), ) cfp_state.add_state_group('UNAVAILABLE', cfp_state.CLOSED, cfp_state.EXPIRED) def __init__(self, **kwargs): super(Project, self).__init__(**kwargs) self.voteset = Voteset(settype=SET_TYPE.PROJECT) self.commentset = Commentset(settype=SET_TYPE.PROJECT) # Add the creator as editor and concierge new_membership = ProjectCrewMembership( parent=self, user=self.user, granted_by=self.user, is_editor=True, is_concierge=True, ) db.session.add(new_membership) def __repr__(self): return '<Project %s/%s "%s">' % ( self.profile.name if self.profile else '(none)', self.name, self.title, ) @with_roles(call={'editor'}) @cfp_state.transition( cfp_state.OPENABLE, cfp_state.PUBLIC, title=__("Enable proposal submissions"), message=__("Proposals can be now submitted"), type='success', ) def open_cfp(self): pass @with_roles(call={'editor'}) @cfp_state.transition( cfp_state.PUBLIC, cfp_state.CLOSED, title=__("Disable proposal submissions"), message=__("Proposals will no longer be accepted"), type='success', ) def close_cfp(self): pass @with_roles(call={'editor'}) @schedule_state.transition( schedule_state.DRAFT, schedule_state.PUBLISHED, title=__("Publish schedule"), message=__("The schedule has been published"), type='success', ) def publish_schedule(self): pass @with_roles(call={'editor'}) @schedule_state.transition( schedule_state.PUBLISHED, schedule_state.DRAFT, title=__("Unpublish schedule"), message=__("The schedule has been moved to draft state"), type='success', ) def unpublish_schedule(self): pass @with_roles(call={'editor'}) @state.transition( state.PUBLISHABLE, state.PUBLISHED, title=__("Publish project"), message=__("The project has been published"), type='success', ) def publish(self): pass @with_roles(call={'editor'}) @state.transition( state.PUBLISHED, state.WITHDRAWN, title=__("Withdraw project"), message=__("The project has been withdrawn and is no longer listed"), type='success', ) def withdraw(self): pass @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) @property def title_inline(self): """Suffix a colon if the title does not end in ASCII sentence punctuation""" if self.title and self.tagline: if not self.title[-1] in ('?', '!', ':', ';', '.', ','): return self.title + ':' return self.title @with_roles(read={'all'}) @property def title_suffix(self): """ Return the profile's title if the project's title doesn't derive from it. Used in HTML title tags to render <title>{{ project }} - {{ suffix }}</title>. """ if not self.title.startswith(self.parent.title): return self.profile.title return '' @with_roles(call={'all'}) def joined_title(self, sep='›'): """Return the project's title joined with the profile's title, if divergent.""" if self.short_title == self.title: # Project title does not derive from profile title, so use both return f"{self.profile.title} {sep} {self.title}" # Project title extends profile title, so profile title is not needed return self.title @with_roles(read={'all'}, datasets={'primary', 'without_parent', 'related'}) @cached_property def datelocation(self): """ Returns a date + location string for the event, the format depends on project dates If it's a single day event > 11 Feb 2018, Bangalore If multi-day event in same month > 09–12 Feb 2018, Bangalore If multi-day event across months > 27 Feb–02 Mar 2018, Bangalore If multi-day event across years > 30 Dec 2018–02 Jan 2019, Bangalore ``datelocation_format`` always keeps ``schedule_end_at`` format as ``–DD Mmm YYYY``. Depending on the scenario mentioned below, format for ``schedule_start_at`` changes. Above examples demonstrate the same. All the possible outputs end with ``–DD Mmm YYYY, Venue``. Only ``schedule_start_at`` format changes. """ daterange = '' if self.schedule_start_at is not None and self.schedule_end_at is not None: schedule_start_at_date = self.schedule_start_at_localized.date() schedule_end_at_date = self.schedule_end_at_localized.date() daterange_format = '{start_date}–{end_date} {year}' if schedule_start_at_date == schedule_end_at_date: # if both dates are same, in case of single day project strf_date = '' daterange_format = '{end_date} {year}' elif schedule_start_at_date.year != schedule_end_at_date.year: # if the start date and end dates are in different years, strf_date = '%d %b %Y' elif schedule_start_at_date.month != schedule_end_at_date.month: # If multi-day event across months strf_date = '%d %b' elif schedule_start_at_date.month == schedule_end_at_date.month: # If multi-day event in same month strf_date = '%d' daterange = daterange_format.format( start_date=schedule_start_at_date.strftime(strf_date), end_date=schedule_end_at_date.strftime('%d %b'), year=schedule_end_at_date.year, ) return ', '.join([_f for _f in [daterange, self.location] if _f]) # TODO: Removing Delete feature till we figure out siteadmin feature # @with_roles(call={'editor'}) # @state.transition( # state.DELETABLE, state.DELETED, title=__("Delete project"), # message=__("The project has been deleted"), type='success') # def delete(self): # pass @db.validates('name', 'profile') def _validate_and_create_redirect(self, key, value): # TODO: When labels, venues and other resources are relocated from project to # profile, this validator can no longer watch profile change. We'll need a more # elaborate transfer mechanism that remaps resources to equivalent ones in the # new profile. if key == 'name': value = value.strip() if value is not None else None if not value or (key == 'name' and not valid_name(value)): raise ValueError(f"Invalid value for {key}: {value!r}") existing_value = getattr(self, key) if value != existing_value and existing_value is not None: ProjectRedirect.add(self) return value @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) @cached_property def cfp_start_at_localized(self): return (localize_timezone(self.cfp_start_at, tz=self.timezone) if self.cfp_start_at else None) @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) @cached_property def cfp_end_at_localized(self): return (localize_timezone(self.cfp_end_at, tz=self.timezone) if self.cfp_end_at else None) @cached_property def location_geonameid(self): return geonameid_from_location( self.location) if self.location else set() def permissions(self, user, inherited=None): # TODO: Remove permission system entirely perms = super(Project, self).permissions(user, inherited) perms.add('view') if user is not None: if self.cfp_state.OPEN: perms.add('new-proposal') if 'editor' in self.roles_for(user): perms.update(( 'view_contactinfo', 'edit_project', 'delete-project', 'confirm-proposal', 'view-venue', 'new-venue', 'edit-venue', 'delete-venue', 'edit-schedule', 'move-proposal', 'view_rsvps', 'new-session', 'edit-session', 'new-event', 'new-ticket-type', 'new_ticket_client', 'edit_ticket_client', 'delete_ticket_client', 'edit_event', 'delete_event', 'admin', 'checkin_event', 'view-event', 'view_ticket_type', 'delete_ticket_type', 'edit-participant', 'view-participant', 'new-participant', 'view_contactinfo', 'confirm-proposal', 'view_voteinfo', 'view_status', 'delete-proposal', 'edit-schedule', 'new-session', 'edit-session', 'view-event', 'view_ticket_type', 'edit-participant', 'view-participant', 'new-participant', )) if 'usher' in self.roles_for(user): perms.add('checkin_event') return perms def roles_for(self, actor=None, anchors=()): roles = super().roles_for(actor, anchors) # https://github.com/hasgeek/funnel/pull/220#discussion_r168718052 roles.add('reader') return roles @classmethod def all_unsorted(cls, legacy=None): """ Return currently active events, not sorted. """ projects = cls.query.outerjoin(Venue).filter(cls.state.PUBLISHED) if legacy is not None: projects = projects.join(Profile).filter(Profile.legacy == legacy) return projects @classmethod # NOQA: A003 def all(cls, legacy=None): # NOQA: A003 """ Return currently active events, sorted by date. """ return cls.all_unsorted(legacy).order_by(cls.schedule_start_at.desc()) @classmethod def fetch_sorted(cls, legacy=None): currently_listed_projects = cls.query.filter_by( parent_project=None).filter(cls.state.PUBLISHED) if legacy is not None: currently_listed_projects = currently_listed_projects.join( Profile).filter(Profile.legacy == legacy) currently_listed_projects = currently_listed_projects.order_by( cls.schedule_start_at.desc()) return currently_listed_projects @classmethod def get(cls, profile_project): """Get a project by its URL slug in the form ``<profile>/<project>``.""" profile_name, project_name = profile_project.split('/') return (cls.query.join(Profile).filter( Profile.name == profile_name, Project.name == project_name).one_or_none()) @classmethod def migrate_profile(cls, old_profile, new_profile): names = {project.name for project in new_profile.projects} for project in old_profile.projects: if project.name in names: current_app.logger.warning( "Project %r had a conflicting name in profile migration, " "so renaming by adding adding random value to name", project, ) project.name += '-' + buid() project.profile = new_profile
class 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
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
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
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
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
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