forget-cancellare-vecchi-toot/model.py

370 lines
13 KiB
Python
Raw Normal View History

2017-08-31 18:59:09 +02:00
from datetime import timedelta, datetime, timezone
2017-07-25 09:52:24 +02:00
from app import db
import secrets
2017-09-20 23:02:36 +02:00
from libforget.interval import decompose_interval
2018-07-06 01:44:30 +02:00
import random
from sqlalchemy.ext.declarative import declared_attr
2017-07-27 00:35:53 +02:00
2017-08-29 14:46:32 +02:00
2017-07-25 10:10:44 +02:00
class TimestampMixin(object):
2017-08-31 18:59:09 +02:00
created_at = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(),
nullable=False)
updated_at = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(),
onupdate=db.func.now(), nullable=False)
2017-07-25 09:52:24 +02:00
2017-07-27 00:35:53 +02:00
def touch(self):
2017-08-29 14:46:32 +02:00
self.updated_at = db.func.now()
2017-07-25 09:52:24 +02:00
class RemoteIDMixin(object):
@property
def service(self):
if not self.id:
return None
return self.id.split(":")[0]
2017-07-25 09:52:24 +02:00
@property
def twitter_id(self):
2017-07-30 01:35:29 +02:00
if not self.id:
return None
if self.service != "twitter":
2017-08-29 14:46:32 +02:00
raise Exception(
"tried to get twitter id for a {} {}"
.format(self.service, type(self)))
return self.id.split(":")[1]
@twitter_id.setter
2017-08-29 21:27:38 +02:00
def twitter_id(self, id_):
self.id = "twitter:{}".format(id_)
@property
def mastodon_instance(self):
if not self.id:
return None
if self.service != "mastodon":
2017-08-29 14:46:32 +02:00
raise Exception(
"tried to get mastodon instance for a {} {}"
.format(self.service, type(self)))
return self.id.split(":", 1)[1].split('@')[1]
@mastodon_instance.setter
def mastodon_instance(self, instance):
self.id = "mastodon:{}@{}".format(self.mastodon_id, instance)
@property
def mastodon_id(self):
if not self.id:
return None
if self.service != "mastodon":
2017-08-29 14:46:32 +02:00
raise Exception(
"tried to get mastodon id for a {} {}"
.format(self.service, type(self)))
return self.id.split(":", 1)[1].split('@')[0]
@mastodon_id.setter
2017-08-29 21:27:38 +02:00
def mastodon_id(self, id_):
self.id = "mastodon:{}@{}".format(id_, self.mastodon_instance)
@property
def remote_id(self):
if self.service == 'twitter':
return self.twitter_id
elif self.service == 'mastodon':
return self.mastodon_id
ThreeWayPolicyEnum = db.Enum('keeponly', 'deleteonly', 'none',
name='enum_3way_policy')
2017-08-01 20:57:15 +02:00
@decompose_interval('policy_delete_every')
@decompose_interval('policy_keep_younger')
class Account(TimestampMixin, RemoteIDMixin):
2017-07-27 00:35:53 +02:00
__tablename__ = 'accounts'
id = db.Column(db.String, primary_key=True)
2017-07-25 09:52:24 +02:00
2017-08-29 14:46:32 +02:00
policy_enabled = db.Column(db.Boolean, server_default='FALSE',
nullable=False)
policy_keep_latest = db.Column(db.Integer, server_default='100',
nullable=False)
2018-01-03 18:00:59 +01:00
policy_keep_favourites = db.Column(ThreeWayPolicyEnum,
server_default='none', nullable=False)
policy_keep_media = db.Column(ThreeWayPolicyEnum, server_default='none',
2017-08-29 14:46:32 +02:00
nullable=False)
policy_delete_every = db.Column(db.Interval, server_default='30 minutes',
nullable=False)
policy_keep_younger = db.Column(db.Interval, server_default='365 days',
nullable=False)
policy_keep_direct = db.Column(db.Boolean, server_default='TRUE',
nullable=False)
2017-07-25 09:52:24 +02:00
display_name = db.Column(db.String)
screen_name = db.Column(db.String)
avatar_url = db.Column(db.String)
reported_post_count = db.Column(db.Integer)
2017-07-27 00:35:53 +02:00
2017-08-31 18:59:09 +02:00
last_fetch = db.Column(db.DateTime(timezone=True),
server_default='epoch', index=True)
last_refresh = db.Column(db.DateTime(timezone=True),
server_default='epoch', index=True)
2017-08-31 21:22:22 +02:00
last_delete = db.Column(db.DateTime(timezone=True), index=True)
next_delete = db.Column(db.DateTime(timezone=True), index=True)
2017-07-27 20:20:59 +02:00
fetch_history_complete = db.Column(db.Boolean, server_default='FALSE',
nullable=False)
@declared_attr
def fetch_current_batch_end_id(cls):
return db.Column(db.String, db.ForeignKey('posts.id', ondelete='SET NULL'))
@declared_attr
def fetch_current_batch_end(cls):
return db.relationship("Post", foreign_keys=(cls.fetch_current_batch_end_id,))
# the declared_attr is necessary because of the foreign key
# and because this class is technically one big mixin
# https://docs.sqlalchemy.org/en/latest/orm/extensions/declarative/mixins.html#mixing-in-relationships
reason = db.Column(db.String)
dormant = db.Column(db.Boolean, server_default='FALSE', nullable=False)
2018-07-06 01:44:30 +02:00
backoff_level = db.Column(db.Integer, server_default='0', nullable=False)
backoff_until = db.Column(db.DateTime(timezone=True), server_default='now', nullable=False)
BACKOFF_MAX = 14
# backoff is 10 seconds * 2^backoff_level
# this gives us roughly 1.8 days at level 14
2017-07-31 00:39:40 +02:00
def touch_fetch(self):
self.last_fetch = db.func.now()
def touch_delete(self):
2017-08-29 17:22:59 +02:00
self.last_delete = db.func.now()
# if it's been more than 1 delete cycle ago that we've deleted a post,
# reset next_delete to be 1 cycle away
2017-08-31 18:59:09 +02:00
if (datetime.now(timezone.utc) - self.next_delete
> self.policy_delete_every):
2017-08-14 21:12:12 +02:00
self.next_delete = db.func.now() + self.policy_delete_every
2017-08-14 20:58:22 +02:00
else:
2017-08-14 21:12:12 +02:00
self.next_delete += self.policy_delete_every
def touch_refresh(self):
self.last_refresh = db.func.now()
def get_avatar(self):
from app import imgproxy
from flask import url_for
return url_for('avatar', identifier=imgproxy.identifier_for(self.avatar_url))
2017-08-01 22:48:33 +02:00
@db.validates('policy_keep_younger', 'policy_delete_every')
def validate_intervals(self, key, value):
if not (value == timedelta(0) or value >= timedelta(minutes=1)):
value = timedelta(minutes=1)
2017-08-29 14:46:32 +02:00
if key == 'policy_delete_every' and \
self.next_delete and\
2017-08-31 18:59:09 +02:00
datetime.now(timezone.utc) + value < self.next_delete:
2017-08-19 13:11:16 +02:00
# make sure that next delete is not in the far future
2017-08-31 18:59:09 +02:00
self.next_delete = datetime.now(timezone.utc) + value
2017-08-01 22:48:33 +02:00
return value
2017-08-29 21:27:38 +02:00
# pylint: disable=R0201
2017-08-07 16:26:25 +02:00
@db.validates('policy_keep_latest')
def validate_empty_string_is_zero(self, key, value):
2017-08-29 21:27:38 +02:00
if isinstance(value, str) and value.strip() == '':
2017-08-07 16:26:25 +02:00
return 0
return value
@db.validates('policy_enabled')
def on_policy_enable(self, key, enable):
if not self.policy_enabled and enable:
self.next_delete = (
datetime.now(timezone.utc) + self.policy_delete_every)
self.reason = None
self.dormant = False
return enable
@db.validates('policy_keep_direct')
def validate_bool_accept_string(self, key, value):
if isinstance(value, str):
return value.lower() == 'true'
return value
# backref: tokens
2017-07-31 20:49:03 +02:00
# backref: twitter_archives
2017-08-01 22:48:33 +02:00
# backref: posts
# backref: sessions
2017-07-27 20:20:59 +02:00
2017-07-31 00:07:34 +02:00
def post_count(self):
return Post.query.with_parent(self, 'posts').count()
2017-07-31 00:07:34 +02:00
2017-08-03 21:37:00 +02:00
def estimate_eligible_for_delete(self):
"""
2017-08-29 14:46:32 +02:00
this is an estimation because we do not know if favourite status has
changed since last time a post was refreshed and it is unfeasible to
refresh every single post every time we need to know how many posts are
eligible to delete
2017-08-03 21:37:00 +02:00
"""
latest_n_posts = (Post.query.with_parent(self, 'posts')
2017-08-29 14:46:32 +02:00
.order_by(db.desc(Post.created_at))
.limit(self.policy_keep_latest))
query = (Post.query.with_parent(self, 'posts')
2017-08-29 14:46:32 +02:00
.filter(Post.created_at <=
db.func.now() - self.policy_keep_younger)
.except_(latest_n_posts))
2018-01-03 18:00:59 +01:00
if(self.policy_keep_favourites != 'none'):
query = query.filter(db.or_(
Post.favourite == (self.policy_keep_favourites == 'deleteonly'),
Post.is_reblog))
if(self.policy_keep_media != 'none'):
query = query.filter(db.or_(
Post.has_media == (self.policy_keep_media == 'deleteonly'),
Post.is_reblog))
if(self.policy_keep_direct):
query = query.filter(~Post.direct)
2017-08-03 21:37:00 +02:00
return query.count()
def force_log_out(self):
Session.query.with_parent(self).delete()
db.session.commit()
2017-08-01 20:57:15 +02:00
2018-07-06 01:44:30 +02:00
def backoff(self):
self.backoff_level = min(self.backoff_level + 1, self.BACKOFF_MAX)
backoff_for = 10 * 2 ** self.backoff_level
backoff_for *= random.uniform(1, 1.3)
self.backoff_until = datetime.utcnow() + timedelta(seconds=backoff_for)
def reset_backoff(self):
self.backoff_until = datetime.utcnow()
self.backoff_level = 0
2017-08-01 20:57:15 +02:00
class Account(Account, db.Model):
def __str__(self):
return f"<Account({self.id}, {self.screen_name}, {self.display_name})>"
2017-08-01 20:57:15 +02:00
2017-07-27 00:35:53 +02:00
class OAuthToken(db.Model, TimestampMixin):
__tablename__ = 'oauth_tokens'
token = db.Column(db.String, primary_key=True)
token_secret = db.Column(db.String, nullable=True)
2017-08-29 14:46:32 +02:00
account_id = db.Column(db.String,
db.ForeignKey('accounts.id', ondelete='CASCADE',
onupdate='CASCADE'),
nullable=True, index=True)
account = db.relationship(
Account,
backref=db.backref('tokens',
order_by=lambda: db.desc(OAuthToken.created_at))
)
2017-07-25 09:52:24 +02:00
2017-08-29 14:46:32 +02:00
# note: account_id is nullable here because we don't know what account a
# token is for until we call /account/verify_credentials with it
class Session(db.Model, TimestampMixin):
__tablename__ = 'sessions'
2017-08-29 14:46:32 +02:00
id = db.Column(db.String, primary_key=True,
2017-08-29 15:17:47 +02:00
default=secrets.token_urlsafe)
2017-08-29 14:46:32 +02:00
account_id = db.Column(
db.String,
db.ForeignKey('accounts.id',
ondelete='CASCADE', onupdate='CASCADE'),
nullable=False, index=True)
account = db.relationship(Account, lazy='joined', backref='sessions')
2017-07-27 20:20:59 +02:00
2017-08-29 14:46:32 +02:00
csrf_token = db.Column(db.String,
2017-08-29 15:17:47 +02:00
default=secrets.token_urlsafe,
2017-08-29 14:46:32 +02:00
nullable=False)
2017-08-25 10:50:11 +02:00
class Post(db.Model, TimestampMixin, RemoteIDMixin):
2017-07-27 20:20:59 +02:00
__tablename__ = 'posts'
id = db.Column(db.String, primary_key=True)
2017-07-27 20:20:59 +02:00
2017-08-29 14:46:32 +02:00
author_id = db.Column(
db.String,
db.ForeignKey('accounts.id',
ondelete='CASCADE', onupdate='CASCADE'),
nullable=False)
author = db.relationship(
Account,
foreign_keys = (author_id,),
2017-08-29 14:46:32 +02:00
backref=db.backref('posts',
order_by=lambda: db.desc(Post.created_at)))
2017-07-31 00:07:34 +02:00
2017-08-02 01:35:09 +02:00
favourite = db.Column(db.Boolean, server_default='FALSE', nullable=False)
has_media = db.Column(db.Boolean, server_default='FALSE', nullable=False)
2017-08-20 18:17:33 +02:00
direct = db.Column(db.Boolean, server_default='FALSE', nullable=False)
favourites = db.Column(db.Integer)
reblogs = db.Column(db.Integer)
2017-12-27 21:23:13 +01:00
is_reblog = db.Column(db.Boolean, server_default='FALSE', nullable=False)
2017-09-02 10:44:21 +02:00
def __str__(self):
2017-08-20 18:05:01 +02:00
return '<Post ({}, Author: {})>'.format(self.id, self.author_id)
2017-08-02 01:35:09 +02:00
2017-08-29 14:46:32 +02:00
2017-08-15 23:58:33 +02:00
db.Index('ix_posts_author_id_created_at', Post.author_id, Post.created_at)
2017-08-29 14:46:32 +02:00
2017-07-31 00:07:34 +02:00
class TwitterArchive(db.Model, TimestampMixin):
__tablename__ = 'twitter_archives'
id = db.Column(db.Integer, primary_key=True)
2017-08-29 14:46:32 +02:00
account_id = db.Column(
db.String,
db.ForeignKey('accounts.id',
onupdate='CASCADE', ondelete='CASCADE'),
nullable=False)
account = db.relationship(
Account,
backref=db.backref('twitter_archives',
order_by=lambda: db.desc(TwitterArchive.id)))
2017-07-31 20:49:03 +02:00
body = db.deferred(db.Column(db.LargeBinary, nullable=False))
2017-07-31 01:57:03 +02:00
chunks = db.Column(db.Integer)
2017-08-29 14:46:32 +02:00
chunks_successful = db.Column(db.Integer,
server_default='0', nullable=False)
chunks_failed = db.Column(db.Integer, server_default='0', nullable=False)
2017-08-07 15:53:34 +02:00
2017-08-07 15:54:42 +02:00
def status(self):
2017-08-07 15:56:22 +02:00
if self.chunks is None or self.chunks_failed > 0:
2017-08-07 15:53:34 +02:00
return 'failed'
if self.chunks_successful == self.chunks:
return 'successful'
return 'pending'
2017-08-29 14:46:32 +02:00
ProtoEnum = db.Enum('http', 'https', name='enum_protocol')
2017-08-29 14:46:32 +02:00
class MastodonApp(db.Model, TimestampMixin):
__tablename__ = 'mastodon_apps'
instance = db.Column(db.String, primary_key=True)
client_id = db.Column(db.String, nullable=False)
client_secret = db.Column(db.String, nullable=False)
protocol = db.Column(ProtoEnum, nullable=False)
2017-08-29 14:46:32 +02:00
class MastodonInstance(db.Model):
"""
this is for the autocomplete in the mastodon login form
it isn't coupled with anything else so that we can seed it with
some popular instances ahead of time
"""
__tablename__ = 'mastodon_instances'
instance = db.Column(db.String, primary_key=True)
popularity = db.Column(db.Float, server_default='10', nullable=False)
def bump(self, value=1):
self.popularity = (self.popularity or 10) + value