class APNDevice(db.Model): """ An APN device to send Push Notifications to. """ __tablename__ = 'apn_devices' __table_args__ = (db.UniqueConstraint('device_id', 'provider'), { 'extend_existing': True }) id = db.Column(db.Integer, primary_key=True, autoincrement=True) uuid = db.Column(db.String(36), unique=True, nullable=False, default=lambda: str(UUID(bytes=rand_bytes(16)))) device_id = db.Column(db.String(64), nullable=True) provider = db.Column(db.Enum(APNProvider), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) user = db.relationship('User', backref='apn_devices', lazy=True) def __repr__(self): return "<APNDevice {} for {}>".format(self.device_id, self.provider.name)
class UserAuthToken(db.Model): """ Represents an authentication scheme for a user based on a JWT-key or OAuth style with an issuer and an identity. You **must** validate the key before inserting it here. """ __tablename__ = 'user_auth_tokens' id = db.Column(db.Integer, primary_key=True, unique=True, autoincrement=True) # Auth method type auth_method = db.Column(db.Enum(AuthTokenType), nullable=False) # Who gives identity and who they are said to be identity = db.Column(db.String(255), nullable=False) issuer = db.Column(db.String(255), nullable=False) # User-facing identifier identifier = db.Column(db.String(255), nullable=False) # Connects to Axtell user user_id = db.Column(db.Integer, db.ForeignKey(User.id), nullable=False) user = db.relationship(User, backref=db.backref('auth_tokens', lazy=True)) def to_json(self): # Get the time of most recent login latest_login = next(iter(self.logins), None) latest_login_time = latest_login and latest_login.time.isoformat() return { 'id': self.id, 'method': self.auth_method.value, 'issuer': self.issuer, 'last_used': latest_login_time, 'identifier': self.identifier } def __repr__(self): return f'<UserToken for {self.user}>'
class Answer(db.Model): """ An answer posted to a post by a user. """ __tablename__ = 'answers' __table_args__ = {'extend_existing': True} id = db.Column(db.Integer, primary_key=True, autoincrement=True) post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=False) language_id = db.Column(db.String(answers['lang_len']), nullable=True, default=None) language_name = db.Column(db.String(answers['lang_len']), nullable=True, default=None) binary_code = db.Column(db.BLOB, default=None, nullable=True) commentary = db.Column(db.Text, default=None, nullable=True) encoding = db.Column(db.String(30), default='utf8') deleted = db.Column(db.Boolean, nullable=False, default=False) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) date_created = db.Column(db.DateTime, default=datetime.datetime.now) index_status = db.Column(db.Enum(IndexStatus), default=IndexStatus.UNSYNCHRONIZED, nullable=False) user = db.relationship('User', backref='answers') post = db.relationship('Post', backref='answers', lazy=True) @property def code(self): return self.binary_code.decode(self.encoding) @code.setter def set_code(self, value): self.binary_code = value.encode(self.encoding) @index_json def get_index_json(self): last_revision = AnswerRevision.query.filter_by( answer_id=self.id).order_by( AnswerRevision.revision_time.desc()).first() if isinstance(last_revision, AnswerRevision): last_modified = last_revision.revision_time else: last_modified = self.date_created language = self.get_language() return { 'objectID': f'answer-{self.id}', 'id': self.id, 'code': self.code, 'date_created': self.date_created.isoformat() + 'Z', 'last_modified': last_modified.isoformat() + 'Z', 'language': language.get_id(), 'language_name': language.get_display_name(), 'byte_count': self.byte_len, 'author': self.user.get_index_json(root_object=False), 'post': { 'id': self.post.id, 'name': self.post.title, 'slug': slugify(self.post.title) } } def should_index(self): return not self.deleted @classmethod @gets_index def get_index(cls): return 'answers' @classmethod def get_index_settings(cls): return { 'searchableAttributes': ['code', 'language_name', 'author.name', 'post.name'], 'attributesToSnippet': ['code', 'language_name', 'author.name', 'post.name'], 'separatorsToIndex': '!#()[]{}*+-_一,:;<>?@/^|%&~£¥$§€`"\'‘’“”†‡' } @hybrid_property def byte_len(self): return len(self.binary_code) @byte_len.expression def byte_len(cls): return func.length(cls.binary_code) @hybrid_property def score(self): ups = sum(1 for vote in self.votes if vote.vote == 1) downs = sum(-1 for vote in self.votes if vote.vote == -1) n = ups - downs if n == 0: return 0 z = 1.0 phat = ups / n return (phat + z * z / (2 * n) - z * sqrt( (phat * (1 - phat) + z * z / (4 * n)) / n)) / (1 + z * z / n) @score.expression def score(cls): ups = select([func.sum(AnswerVote.vote) ]).where(AnswerVote.answer_id == cls.id and AnswerVote.vote == 1).label('ups') downs = select([func.sum(AnswerVote.vote) ]).where(AnswerVote.answer_id == cls.id and AnswerVote.vote == -1).label('downs') n = ups - downs if n == 0: return 0 z = 1.0 phat = ups / n return (phat + z * z / (2 * n) - z * func.sqrt( (phat * (1 - phat) + z * z / (4 * n)) / n)) / (1 + z * z / n) def to_json(self, no_code=False): data = {} if not no_code: data['code'] = self.code data['commentary'] = self.commentary data['id'] = self.id data['encoding'] = self.encoding data['byte_len'] = self.byte_len language = self.get_language() if language is not None: data['lang'] = language.to_json() data['owner'] = self.user.to_json() data['date_created'] = self.date_created.isoformat() data['deleted'] = self.deleted return data def get_language(self): if self.language_id is None: return None return app.models.Language.Language(self.language_id) def revise(self, user, **new_answer_data): revision = AnswerRevision(answer_id=self.id, language_id=self.language_id, language_name=self.language_name, binary_code=self.binary_code, commentary=self.commentary, encoding=self.encoding, deleted=self.deleted, user_id=user.id) if 'code' in new_answer_data: self.binary_code = new_answer_data['code'].encode( new_answer_data.get('encoding', self.encoding) or 'utf8') if 'commentary' in new_answer_data: self.commentary = new_answer_data['commentary'] if 'encoding' in new_answer_data: self.encoding = new_answer_data['encoding'] if 'deleted' in new_answer_data: self.deleted = new_answer_data['deleted'] self.index_status = IndexStatus.UNSYNCHRONIZED return self, revision def __repr__(self): return '<Answer(%r) by %r %s>' % (self.id, self.user.name, "(deleted)" if self.deleted else "")
class Notification(db.Model): """ Represents a notification sent to a user """ __tablename__ = 'notifications' __table_args__ = {'extend_existing': True} id = db.Column(db.Integer, primary_key=True, autoincrement=True) uuid = db.Column(db.String(36), unique=True, nullable=False, default=lambda: str(UUID(bytes=urandom(16)))) recipient_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) recipient = db.relationship('User', foreign_keys=[recipient_id], backref='notifications', lazy='joined') sender_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) sender = db.relationship('User', foreign_keys=[sender_id], backref='sent_notifications', lazy='joined') # The type of notification see the NotificationType enum class notification_type = db.Column(db.Enum(NotificationType), nullable=False) # An ID referencing the item which dispatches the notification target_id = db.Column(db.Integer, nullable=True) # The id of the subscription source source_id = db.Column(db.Integer, nullable=True) date_created = db.Column(db.DateTime, default=datetime.datetime.now) read = db.Column(db.Enum(NotificationStatus), default=NotificationStatus.UNSEEN) def get_target_descriptor(self): """ Returns the 'category' of the type's associated payload. This is used in the responder router """ return { NotificationType.STATUS_UPDATE: 'status', NotificationType.NEW_ANSWER: 'answer', NotificationType.OUTGOLFED: 'answer', NotificationType.NEW_POST_COMMENT: 'post_comment', NotificationType.NEW_ANSWER_COMMENT: 'answer_comment', NotificationType.ANSWER_VOTE: 'answer', NotificationType.POST_VOTE: 'answer' }[self.notification_type] def get_title(self): # TODO: more descriptive titles return { NotificationType.STATUS_UPDATE: lambda: "Status Update", NotificationType.NEW_ANSWER: lambda: get_title.new_answer(self), NotificationType.OUTGOLFED: lambda: get_title.outgolfed(self), NotificationType.NEW_POST_COMMENT: lambda: get_title.post_comment(self), NotificationType.NEW_ANSWER_COMMENT: lambda: get_title.answer_comment(self), NotificationType.ANSWER_VOTE: lambda: "Someone voted on your answer", NotificationType.POST_VOTE: lambda: "Someone voted on your post" }[self.notification_type]() def get_body(self): # TODO: more descriptive bodies return { NotificationType.STATUS_UPDATE: lambda: "You're received a brand new status update.", NotificationType.NEW_ANSWER: lambda: get_body.new_answer(self), NotificationType.OUTGOLFED: lambda: get_body.outgolfed(self), NotificationType.NEW_POST_COMMENT: lambda: "A new comment has been posted on your challenge.", NotificationType.NEW_ANSWER_COMMENT: lambda: "A new comment has been posted on your answer.", NotificationType.ANSWER_VOTE: lambda: "A new vote has come upon your answer.", NotificationType.POST_VOTE: lambda: "A new vote has come upon your challenge." }[self.notification_type]() def get_plural(self): """ Gets plural of notification type """ return { NotificationType.STATUS_UPDATE: lambda: "updates", NotificationType.NEW_ANSWER: lambda: "new answers", NotificationType.OUTGOLFED: lambda: "outgolfs", NotificationType.NEW_POST_COMMENT: lambda: "new comments", NotificationType.NEW_ANSWER_COMMENT: lambda: "new comments", NotificationType.ANSWER_VOTE: lambda: "votes", NotificationType.POST_VOTE: lambda: "votes" }[self.notification_type]() def is_overwriting(self): """ Gets if this notification type should be grouped and have older instances discarded. """ return { NotificationType.STATUS_UPDATE: False, NotificationType.NEW_ANSWER: False, NotificationType.OUTGOLFED: True, NotificationType.NEW_POST_COMMENT: False, NotificationType.NEW_ANSWER_COMMENT: False, NotificationType.ANSWER_VOTE: True, NotificationType.POST_VOTE: True }[self.notification_type] def to_apns_json(self): """ Returns APNS compliant JSON payload """ target = str(self.target_id) if self.target_id is not None else '_' return { 'aps': { 'alert': { 'title': self.get_title(), 'body': self.get_body(), 'action': 'View' }, 'url-args': [self.uuid, self.get_target_descriptor(), target] } } def to_push_json(self): return { 'id': self.uuid, 'title': self.get_title(), 'body': self.get_body(), 'category': self.get_target_descriptor(), 'source': self.source_id, 'target': self.target_id, 'overwriting': self.is_overwriting(), 'date': self.date_created.isoformat() + 'Z' } def to_json(self): return { 'id': self.uuid, 'title': self.get_title(), 'body': self.get_body(), 'plural': self.get_plural(), 'recipient': self.recipient.to_json(), 'source_id': self.source_id, 'sender': self.sender.to_json(), 'target_id': self.target_id, 'category': self.get_target_descriptor(), 'date_created': self.date_created.isoformat() + 'Z', 'type': self.notification_type.value, 'status': self.read.value } def __repr__(self): return "<Notification about {} for {}>".format( self.notification_type.name, self.recipient.name)
class User(db.Model): """ Self-explanatory, a user. """ __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True, unique=True, autoincrement=True) name = db.Column(db.String(config.users['max_name_len']), nullable=False) email = db.Column(db.String(320)) avatar = db.Column(db.String(256), nullable=True) posts = db.relationship('Post', backref='user') theme = db.Column(db.Integer, db.ForeignKey('themes.id'), nullable=True) receive_notifications = db.Column(db.Boolean, nullable=False, default=True) index_status = db.Column(db.Enum(IndexStatus), default=IndexStatus.UNSYNCHRONIZED, nullable=False) following_public = db.Column(db.Boolean, nullable=False, default=True) linked_stackexchange_public = db.Column(db.Boolean, nullable=False, default=False) is_admin = db.Column(db.Boolean, nullable=False, default=False) deleted = db.Column(db.Boolean, nullable=False, default=False) @index_json def get_index_json(self): return { 'objectID': f'user-{self.id}', 'id': self.id, 'name': self.name, 'avatar': self.avatar_url() } def should_index(self): return not self.deleted @classmethod @gets_index def get_index(cls): return 'users' @classmethod def get_index_settings(cls): return { 'searchableAttributes': ['name'], 'attributesToSnippet': ['name'] } def follow(self, user): """ Makes (self) User follow the given user. You can't follow yourself :param User user: The user which this instance _will_ follow """ if not user.followed_by(self) and user.id != self.id: self.following.append(user) def unfollow(self, user): """ Makes (self) User not follow the given user. :param User user: The user which this instance _will not_ follow """ if user.followed_by(self): self.following.remove(user) def followed_by(self, user): """ Checks if a provided user follows the user referenced by `self` :param User user: The user which will be checked if following :return: boolean indicating """ return self.followers.filter_by(id=user.id).scalar() is not None def avatar_url(self): if self.avatar is not None: return self.avatar else: return gravatar(self.email) def to_json(self, current_user=None, own=False, bio=False): data = { 'id': self.id, 'name': self.name, 'avatar': self.avatar_url(), 'is_admin': self.is_admin } if current_user is not None: data['is_following'] = self.followed_by(current_user) if own: data['email'] = self.email data['receive_notifications'] = self.receive_notifications if bio: data['post_count'] = len(self.posts) data['answer_count'] = len(self.answers) return data def __repr__(self): return '<User({!r}) "{!r}">'.format(self.id, self.name)
class Post(db.Model): """ Represnts a post (e.g. challenge) """ __tablename__ = 'posts' __table_args__ = {'extend_existing': True} id = db.Column(db.Integer, primary_key=True, autoincrement=True) title = db.Column(db.String(posts['max_title']), nullable=False) body = db.Column(LONGTEXT, nullable=False) deleted = db.Column(db.Boolean, nullable=False, default=False) date_created = db.Column(db.DateTime, default=datetime.datetime.now) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) index_status = db.Column(db.Enum(IndexStatus), default=IndexStatus.UNSYNCHRONIZED, nullable=False) ppcg_id = db.Column(db.Integer, nullable=True) @index_json def get_index_json(self): last_revision = PostRevision.query.filter_by(post_id=self.id).order_by( PostRevision.revision_time.desc()).first() if isinstance(last_revision, PostRevision): last_modified = last_revision.revision_time else: last_modified = self.date_created return { 'objectID': f'post-{self.id}', 'id': self.id, 'title': self.title, 'body': render_markdown(self.body, render_math=False), 'slug': slugify(self.title), 'date_created': self.date_created.isoformat() + 'Z', 'last_modified': last_modified.isoformat() + 'Z', 'score': self.score, 'author': self.user.get_index_json(root_object=False) } def should_index(self): return not self.deleted def get_view_count(self): return get_post_views(self.id) def get_hotness(self, views, answers): """ Gets hotness value on scale of [0, 1] """ time_delta = datetime.datetime.now() - self.date_created view_component = 1 - exp(-views / hotness['view_weight']) * 0.5 answer_component = 1 - exp(-answers / hotness['answer_weight']) * 2 time_component = exp(-time_delta.days / hotness['time_weight']) return (view_component + answer_component + time_component) / 3 @classmethod @gets_index def get_index(cls): return 'posts' @classmethod def get_index_settings(cls): return { 'searchableAttributes': ['body', 'title', 'author.name'], 'attributesToSnippet': ['title', 'body:15', 'author.name'] } @hybrid_property def score(self): ups = sum(1 for vote in self.votes if vote.vote == 1) downs = sum(-1 for vote in self.votes if vote.vote == -1) n = ups - downs if n == 0: return 0 z = 1.0 phat = ups / n return (phat + z * z / (2 * n) - z * sqrt( (phat * (1 - phat) + z * z / (4 * n)) / n)) / (1 + z * z / n) @score.expression def score(cls): ups = select([func.sum(PostVote.vote) ]).where(PostVote.post_id == cls.id and PostVote.vote == 1).label('ups') downs = select([func.sum(PostVote.vote) ]).where(PostVote.post_id == cls.id and PostVote.vote == -1).label('downs') n = ups - downs if n == 0: return 0 z = 1.0 phat = ups / n return (phat + z * z / (2 * n) - z * func.sqrt( (phat * (1 - phat) + z * z / (4 * n)) / n)) / (1 + z * z / n) @hybrid_property def age(self): last_revision = PostRevision.query.filter_by(post_id=self.id).order_by( PostRevision.revision_time.desc()).first() if isinstance(last_revision, PostRevision): last_modified = last_revision.revision_time else: last_modified = self.date_created return last_modified @age.expression def age(cls): last_revision = select([func.coalesce(PostRevision.revision_time, cls.date_created)]).\ where(PostRevision.post_id == cls.id).\ order_by(PostRevision.revision_time.desc()).\ limit(1)[0] return last_revision[0] @hybrid_property def activity(self): last_answer_revision = select([func.coalesce(AnswerRevision.revision_time, Answer.date_created)].label('last_revision')).\ select_from(Answer.join(AnswerRevision)).\ where(Answer.post_id == self.id).\ order_by(desc('last_revision')).\ limit(1) if len(last_answer_revision) == 0: return self.age() else: return last_answer_revision[0][0] def to_json(self, no_body=False): json = { 'id': self.id, 'title': self.title, 'owner': self.user.to_json(), 'slug': slugify(self.title), 'date_created': self.date_created.isoformat(), 'deleted': self.deleted } if not no_body: json['body'] = self.body return json def revise(self, user, **new_post_data): revision = PostRevision(post_id=self.id, title=self.title, body=self.body, deleted=self.deleted, user_id=user.id) self.title = new_post_data.get('title', self.title) self.body = new_post_data.get('body', self.body) self.deleted = new_post_data.get('deleted', self.deleted) self.ppcg_id = new_post_data.get('ppcg_id', self.ppcg_id) self.index_status = IndexStatus.UNSYNCHRONIZED return self, revision def __repr__(self): return '<Post(%r) by %r %s>' % (self.id, self.user.name, "(deleted)" if self.deleted else "")