You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wownero-funding-system/funding/orm/orm.py

463 lines
16 KiB

from datetime import datetime
import sqlalchemy as sa
from sqlalchemy.orm import scoped_session, sessionmaker, relationship
from sqlalchemy.ext.declarative import declarative_base
import settings
base = declarative_base(name="Model")
class User(base):
__tablename__ = "users"
id = sa.Column('user_id', sa.Integer, primary_key=True)
username = sa.Column(sa.String(20), unique=True, index=True)
password = sa.Column(sa.String(60))
email = sa.Column(sa.String(50), unique=True, index=True)
registered_on = sa.Column(sa.DateTime)
admin = sa.Column(sa.Boolean, default=False)
proposals = relationship('Proposal', back_populates="user")
comments = relationship("Comment", back_populates="user")
def __init__(self, username, password, email):
from funding.factory import bcrypt
self.username = username
self.password = bcrypt.generate_password_hash(password).decode('utf8')
self.email = email
self.registered_on = datetime.utcnow()
@property
def is_authenticated(self):
return True
@property
def is_active(self):
return True
@property
def is_anonymous(self):
return False
@property
def is_admin(self):
return self.admin
def get_id(self):
return self.id
def __repr__(self):
return self.username
@classmethod
def add(cls, username, password, email):
from funding.factory import db_session
from funding.validation import val_username, val_email
try:
# validate incoming username/email
val_username(username)
val_email(email)
user = User(username, password, email)
db_session.add(user)
db_session.commit()
db_session.flush()
return user
except Exception as ex:
db_session.rollback()
raise
class Proposal(base):
__tablename__ = "proposals"
id = sa.Column(sa.Integer, primary_key=True)
headline = sa.Column(sa.VARCHAR, nullable=False)
content = sa.Column(sa.VARCHAR, nullable=False)
category = sa.Column(sa.VARCHAR, nullable=False)
date_added = sa.Column(sa.TIMESTAMP, default=datetime.now)
html = sa.Column(sa.VARCHAR)
last_edited = sa.Column(sa.TIMESTAMP)
# the FFS target
funds_target = sa.Column(sa.Float, nullable=False)
# the FFS progress (cached)
funds_progress = sa.Column(sa.Float, nullable=False, default=0)
# the FFS withdrawal amount (paid to the author)
funds_withdrew = sa.Column(sa.Float, nullable=False, default=0)
# the FFS receiving and withdrawal addresses
addr_donation = sa.Column(sa.VARCHAR)
addr_receiving = sa.Column(sa.VARCHAR)
# proposal status:
# 0: disabled
# 1: proposed
# 2: funding required
# 3: wip
# 4: completed
status = sa.Column(sa.INTEGER, default=1)
user_id = sa.Column(sa.Integer, sa.ForeignKey('users.user_id'))
user = relationship("User", back_populates="proposals")
payouts = relationship("Payout", back_populates="proposal")
comments = relationship("Comment", back_populates="proposal", lazy='select')
def __init__(self, headline, content, category, user: User):
if not headline or not content:
raise Exception('faulty proposal')
self.headline = headline
self.content = content
self.user_id = user.id
if category not in settings.FUNDING_CATEGORIES:
raise Exception('wrong category')
self.category = category
@property
def json(self):
return {
'date_posted_epoch': self.date_added.strftime('%s'),
'date_posted': self.date_added.strftime('%b %d %Y %H:%M:%S'),
'headline': self.headline,
'content_markdown': self.content,
'category': self.category,
'funds_target': self.funds_target,
'funded_pct': self.funds_progress,
'addr_donation': self.addr_donation,
'status': self.status,
'user': self.user.username,
'id': self.id
}
@classmethod
def find_by_id(cls, pid: int):
from funding.factory import db_session
q = cls.query
q = q.filter(Proposal.id == pid)
result = q.first()
if not result:
return
# check if we have a valid addr_donation generated. if not, make one.
if not result.addr_donation and result.status == 2:
from funding.bin.daemon import Daemon
Proposal.generate_donation_addr(result)
return result
@property
def funds_target_usd(self):
from funding.bin.utils import Summary, coin_to_usd
prices = Summary.fetch_prices()
if not prices:
return
return coin_to_usd(amt=self.funds_target, btc_per_coin=prices['coin-btc'], usd_per_btc=prices['btc-usd'])
@property
def comment_count(self):
from funding.factory import db_session
q = db_session.query(sa.func.count(Comment.id))
q = q.filter(Comment.proposal_id == self.id)
return q.scalar()
def get_comments(self):
from funding.factory import db_session
q = db_session.query(Comment)
q = q.filter(Comment.proposal_id == self.id)
q = q.filter(Comment.replied_to == None)
q = q.order_by(Comment.date_added.desc())
comments = q.all()
for c in comments:
q = db_session.query(Comment)
q = q.filter(Comment.proposal_id == self.id)
q = q.filter(Comment.replied_to == c.id)
_c = q.all()
setattr(c, 'comments', _c)
setattr(self, '_comments', comments)
return self
@property
def balance(self):
"""This property retrieves the current funding status
of this proposal. It uses Redis cache to not spam the
daemon too much. Returns a nice dictionary containing
all relevant proposal funding info"""
from funding.bin.utils import Summary, coin_to_usd
from funding.factory import cache, db_session
rtn = {'sum': 0.0, 'txs': [], 'pct': 0.0}
cache_key = 'coin_balance_pid_%d' % self.id
data = cache.get(cache_key)
if not data:
from funding.bin.daemon import Daemon
try:
data = Daemon().get_transfers_in(proposal=self)
if not isinstance(data, dict):
print('error; get_transfers_in; %d' % self.id)
return rtn
cache.set(cache_key, data=data, expiry=300)
except Exception as ex:
print('error; get_transfers_in; %d' % self.id)
return rtn
prices = Summary.fetch_prices()
for tx in data['txs']:
if prices:
tx['amount_usd'] = coin_to_usd(amt=tx['amount_human'], btc_per_coin=prices['coin-btc'], usd_per_btc=prices['btc-usd'])
tx['datetime'] = datetime.fromtimestamp(tx['timestamp'])
if data.get('sum', 0.0):
data['pct'] = 100 / float(self.funds_target / data.get('sum', 0.0))
data['available'] = data['sum']
else:
data['pct'] = 0.0
data['available'] = 0.0
if data['pct'] != self.funds_progress:
self.funds_progress = data['pct']
db_session.commit()
db_session.flush()
if data['available']:
data['remaining_pct'] = 100 / float(data['sum'] / data['available'])
else:
data['remaining_pct'] = 0.0
return data
@property
def spends(self):
from funding.bin.utils import Summary, coin_to_usd
from funding.factory import cache, db_session
rtn = {'sum': 0.0, 'txs': [], 'pct': 0.0}
cache_key = 'coin_spends_pid_%d' % self.id
data = cache.get(cache_key)
if not data:
from funding.bin.daemon import Daemon
try:
data = Daemon().get_transfers_out(proposal=self)
if not isinstance(data, dict):
print('error; get_transfers_out; %d' % self.id)
return rtn
cache.set(cache_key, data=data, expiry=300)
except:
print('error; get_transfers_out; %d' % self.id)
return rtn
prices = Summary.fetch_prices()
for tx in data['txs']:
if prices:
tx['amount_usd'] = coin_to_usd(amt=tx['amount_human'], btc_per_coin=prices['coin-btc'], usd_per_btc=prices['btc-usd'])
tx['datetime'] = datetime.fromtimestamp(tx['timestamp'])
if data.get('sum', 0.0):
data['pct'] = 100 / float(self.funds_target / data.get('sum', 0.0))
data['spent'] = data['sum']
else:
data['pct'] = 0.0
data['spent'] = 0.0
if data['spent']:
data['remaining_pct'] = 100 / float(data['sum'] / data['spent'])
else:
data['remaining_pct'] = 0.0
return data
@staticmethod
def generate_donation_addr(cls):
from funding.factory import db_session
from funding.bin.daemon import Daemon
if cls.addr_donation:
return cls.addr_donation
# check if the current user has an account in the wallet
account = Daemon().get_accounts(cls.id)
if not account:
account = Daemon().create_account(cls.id)
index = account['account_index']
address = account.get('address') or account.get('base_address')
if not address:
raise Exception('Cannot generate account/address for pid %d' % cls.id)
# assign donation address, commit to db
cls.addr_donation = address
db_session.commit()
db_session.flush()
return address
@classmethod
def find_by_args(cls, status: int = None, cat: str = None, limit: int = 20, offset=0):
from funding.factory import db_session
if isinstance(status, int) and status not in settings.FUNDING_STATUSES.keys():
raise NotImplementedError('invalid status')
if isinstance(cat, str) and cat not in settings.FUNDING_CATEGORIES:
raise NotImplementedError('invalid cat')
q = cls.query
if isinstance(status, int):
q = q.filter(Proposal.status == status)
if cat:
q = q.filter(Proposal.category == cat)
q = q.order_by(Proposal.date_added.desc())
q = q.limit(limit)
if isinstance(offset, int):
q = q.offset(offset)
return q.all()
@classmethod
def search(cls, key: str):
key_ilike = '%' + key.replace('%', '') + '%'
q = Proposal.query
q = q.filter(sa.or_(
Proposal.headline.ilike(key_ilike),
Proposal.content.ilike(key_ilike)))
return q.all()
class Payout(base):
__tablename__ = "payouts"
id = sa.Column(sa.Integer, primary_key=True)
proposal_id = sa.Column(sa.Integer, sa.ForeignKey('proposals.id'))
proposal = relationship("Proposal", back_populates="payouts")
amount = sa.Column(sa.Integer, nullable=False)
to_address = sa.Column(sa.VARCHAR, nullable=False)
ix_proposal_id = sa.Index("ix_proposal_id", proposal_id)
@classmethod
def add(cls, proposal_id, amount, to_address):
# @TODO: validate that we can make this payout; check previous payouts
from flask.ext.login import current_user
if not current_user.admin:
raise Exception("user must be admin to add a payout")
from funding.factory import db_session
try:
payout = Payout(propsal_id=proposal_id, amount=amount, to_address=to_address)
db_session.add(payout)
db_session.commit()
db_session.flush()
return payout
except Exception as ex:
db_session.rollback()
raise
@staticmethod
def get_payouts(proposal_id):
from funding.factory import db_session
return db_session.query(Payout).filter(Payout.proposal_id == proposal_id).all()
class Comment(base):
__tablename__ = "comments"
id = sa.Column(sa.Integer, primary_key=True)
proposal_id = sa.Column(sa.Integer, sa.ForeignKey('proposals.id'))
proposal = relationship("Proposal", back_populates="comments")
user_id = sa.Column(sa.Integer, sa.ForeignKey('users.user_id'), nullable=False)
user = relationship("User", back_populates="comments")
date_added = sa.Column(sa.TIMESTAMP, default=datetime.now)
message = sa.Column(sa.VARCHAR, nullable=False)
replied_to = sa.Column(sa.ForeignKey("comments.id"))
locked = sa.Column(sa.Boolean, default=False)
automated = sa.Column(sa.Boolean, default=False)
ix_comment_replied_to = sa.Index("ix_comment_replied_to", replied_to)
ix_comment_proposal_id = sa.Index("ix_comment_proposal_id", proposal_id)
@property
def ago(self):
from funding.bin.utils_time import TimeMagic
return TimeMagic().ago(self.date_added)
@staticmethod
def find_by_id(cid: int):
from funding.factory import db_session
return db_session.query(Comment).filter(Comment.id == cid).first()
@staticmethod
def remove(cid: int):
from funding.factory import db_session
from flask.ext.login import current_user
if current_user.id != user_id and not current_user.admin:
raise Exception("no rights to remove this comment")
comment = Comment.get(cid=cid)
try:
comment.delete()
db_session.commit()
db_session.flush()
except:
db_session.rollback()
raise
@staticmethod
def lock(cid: int):
from funding.factory import db_session
from flask.ext.login import current_user
if not current_user.admin:
raise Exception("admin required")
comment = Comment.find_by_id(cid=cid)
if not comment:
raise Exception("comment by that id not found")
comment.locked = True
try:
db_session.commit()
db_session.flush()
return comment
except:
db_session.rollback()
raise
@classmethod
def add_comment(cls, pid: int, user_id: int, message: str, cid: int = None, message_id: int = None, automated=False):
from flask.ext.login import current_user
from funding.factory import db_session
if not message:
raise Exception("empty message")
if current_user.id != user_id and not current_user.admin:
raise Exception("no rights to add or modify this comment")
if not message_id:
proposal = Proposal.find_by_id(pid=pid)
if not proposal:
raise Exception("no proposal by that id")
comment = Comment(user_id=user_id, proposal_id=proposal.id, automated=automated)
if cid:
parent = Comment.find_by_id(cid=cid)
if not parent:
raise Exception("cannot reply to a non-existent comment")
comment.replied_to = parent.id
else:
try:
user = db_session.query(User).filter(User.id == user_id).first()
if not user:
raise Exception("no user by that id")
comment = next(c for c in user.comments if c.id == message_id)
if comment.locked and not current_user.admin:
raise Exception("your comment has been locked/removed")
except StopIteration:
raise Exception("no message by that id")
except:
raise Exception("unknown error")
try:
comment.message = message
db_session.add(comment)
db_session.commit()
db_session.flush()
except Exception as ex:
db_session.rollback()
raise Exception(str(ex))
return comment