Skip to content
This repository has been archived by the owner on Feb 8, 2018. It is now read-only.

Commit

Permalink
Merge pull request #482 from joonas/migrate-to-orm
Browse files Browse the repository at this point in the history
Migrate to using an ORM, addresses #446
  • Loading branch information
chadwhitacre committed Jan 17, 2013
2 parents e20b138 + 8c5f668 commit e03cc8a
Show file tree
Hide file tree
Showing 25 changed files with 609 additions and 208 deletions.
111 changes: 17 additions & 94 deletions gittip/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,133 +3,56 @@
import datetime
import rfc822
import time
import uuid

import pytz
from aspen import Response
from aspen.utils import typecheck
from sqlalchemy.engine.base import RowProxy
from gittip.orm.tables import participants
from gittip.participant import Participant
from psycopg2.extras import RealDictRow

from gittip.orm import db
from gittip.models import User

BEGINNING_OF_EPOCH = rfc822.formatdate(0)
TIMEOUT = 60 * 60 * 24 * 7 # one week


class User(Participant):
"""Represent a website user.
Every current website user is also a participant, though if the user is
anonymous then the methods from Participant will fail with NoParticipantId.
"""

def __init__(self, session):
"""Takes a dict of user info.
"""
typecheck(session, (RealDictRow, RowProxy, dict, None))
if session is None:
session = {}
self.session = dict(session)
participant_id = self.session.get('id')
super(User, self).__init__(participant_id) # sets self.id

@classmethod
def from_session_token(cls, token):
user = participants.select().where(
participants.c.session_token == token,
).where(
participants.c.is_suspicious.isnot(True),
).execute().fetchone()
return cls(user)

@classmethod
def from_id(cls, participant_id):
user = participants.select().where(
participants.c.id == participant_id,
).where(
participants.c.is_suspicious.isnot(True),
).execute().fetchone()
session = dict(user)
session['session_token'] = uuid.uuid4().hex
participants.update().where(
participants.c.id == participant_id
).values(
session_token = session['session_token'],
).execute()
return cls(session)

@staticmethod
def load_session(SESSION, val):
from gittip import db
rec = db.fetchone(SESSION, (val,))
out = {}
if rec is not None:
out = rec
return out

def __str__(self):
return '<User: %s>' % getattr(self, 'id', 'Anonymous')
__repr__ = __str__

def __getattr__(self, name):
return self.session.get(name)

@property
def ADMIN(self):
return bool(self.session.get('is_admin', False))

@property
def ANON(self):
return self.id is None


def inbound(request):
"""Authenticate from a cookie.
"""
if 'session' in request.headers.cookie:
token = request.headers.cookie['session'].value
user = User.from_session_token(token)
else:
user = User({})
user = User()
request.context['user'] = user


def outbound(response):
from gittip import db
session = {}
if 'user' in response.request.context:
user = response.request.context['user']
if not isinstance(user, User):
raise Response(400, "If you define 'user' in a simplate it has to "
"be a User instance.")
session = user.session
if not session: # user is anonymous
else:
user = User()

if user.ANON: # user is anonymous
if 'session' not in response.request.headers.cookie:
# no cookie in the request, don't set one on response
return
else:
# expired cookie in the request, instruct browser to delete it
response.headers.cookie['session'] = ''
expires = 0
else: # user is authenticated
else: # user is authenticated
user = User.from_session_token(user.session_token)
response.headers['Expires'] = BEGINNING_OF_EPOCH # don't cache
response.headers.cookie['session'] = session['session_token']
expires = session['session_expires'] = time.time() + TIMEOUT
SQL = """
UPDATE participants SET session_expires=%s WHERE session_token=%s
"""
db.execute( SQL
, ( datetime.datetime.fromtimestamp(expires)
, session['session_token']
)
)
response.headers.cookie['session'] = user.session_token
expires = time.time() + TIMEOUT
user.session_expires = datetime.datetime.fromtimestamp(expires)\
.replace(tzinfo=pytz.utc)
db.session.add(user)
db.session.commit()

cookie = response.headers.cookie['session']
# I am not setting domain, because it is supposed to default to what we
# want: the domain of the object requested.
#cookie['domain']
cookie['path'] = '/'
cookie['expires'] = rfc822.formatdate(expires)
cookie['httponly'] = "Yes, please."
cookie['httponly'] = "Yes, please."
10 changes: 10 additions & 0 deletions gittip/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from gittip.models.absorption import Absorption
from gittip.models.elsewhere import Elsewhere
from gittip.models.exchange import Exchange
from gittip.models.participant import Participant
from gittip.models.payday import Payday
from gittip.models.tip import Tip
from gittip.models.transfer import Transfer
from gittip.models.user import User

all = [Elsewhere, Exchange, Participant, Payday, Tip, Transfer, User]
18 changes: 18 additions & 0 deletions gittip/models/absorption.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from sqlalchemy.schema import Column, ForeignKey
from sqlalchemy.types import Integer, Text, TIMESTAMP

from gittip.orm import db

class Absorption(db.Model):
__tablename__ = 'absorptions'

id = Column(Integer, nullable=False, primary_key=True)
timestamp = Column(TIMESTAMP(timezone=True), nullable=False,
default="now()")
absorbed_was = Column(Text, nullable=False)
absorbed_by = Column(Text, ForeignKey("participants.id",
onupdate="CASCADE",
ondelete="RESTRICT"), nullable=False)
archived_as = Column(Text, ForeignKey("participants.id",
onupdate="RESTRICT",
ondelete="RESTRICT"), nullable=False)
30 changes: 30 additions & 0 deletions gittip/models/elsewhere.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from sqlalchemy.dialects.postgresql.hstore import HSTORE
from sqlalchemy.schema import Column, UniqueConstraint, ForeignKey
from sqlalchemy.types import Integer, Text, Boolean

from gittip.orm import db

class Elsewhere(db.Model):
__tablename__ = 'elsewhere'
__table_args__ = (
UniqueConstraint('platform', 'participant_id',
name='elsewhere_platform_participant_id_key'),
UniqueConstraint('platform', 'user_id',
name='elsewhere_platform_user_id_key')
)

id = Column(Integer, nullable=False, primary_key=True)
platform = Column(Text, nullable=False)
user_id = Column(Text, nullable=False)
user_info = Column(HSTORE)
is_locked = Column(Boolean, default=False, nullable=False)
participant_id = Column(Text, ForeignKey("participants.id"), nullable=False)

def resolve_unclaimed(self):
if self.platform == 'github':
out = '/on/github/%s/' % self.user_info['login']
elif self.platform == 'twitter':
out = '/on/twitter/%s/' % self.user_info['screen_name']
else:
out = None
return out
16 changes: 16 additions & 0 deletions gittip/models/exchange.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from sqlalchemy.schema import Column, ForeignKey
from sqlalchemy.types import Integer, Numeric, Text, TIMESTAMP

from gittip.orm import db

class Exchange(db.Model):
__tablename__ = 'exchanges'

id = Column(Integer, nullable=False, primary_key=True)
timestamp = Column(TIMESTAMP(timezone=True), nullable=False,
default="now()")
amount = Column(Numeric(precision=35, scale=2), nullable=False)
fee = Column(Numeric(precision=35, scale=2), nullable=False)
participant_id = Column(Text, ForeignKey("participants.id",
onupdate="CASCADE", ondelete="RESTRICT"),
nullable=False)
163 changes: 163 additions & 0 deletions gittip/models/participant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import datetime
from decimal import Decimal

import pytz
from sqlalchemy import select, func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import relationship
from sqlalchemy.schema import Column, CheckConstraint, UniqueConstraint
from sqlalchemy.types import Text, TIMESTAMP, Boolean, Numeric
from aspen import Response

import gittip
from gittip.orm import db
# This is loaded for now to maintain functionality until the class is fully
# migrated over to doing everything using SQLAlchemy
from gittip.participant import Participant as ParticipantClass

ASCII_ALLOWED_IN_PARTICIPANT_ID = set("0123456789"
"abcdefghijklmnopqrstuvwxyz"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
".,-_;:@ ")

class Participant(db.Model):
__tablename__ = "participants"
__table_args__ = (
UniqueConstraint("session_token",
name="participants_session_token_key"),
)

id = Column(Text, nullable=False, primary_key=True)
statement = Column(Text, default="", nullable=False)
stripe_customer_id = Column(Text)
last_bill_result = Column(Text)
session_token = Column(Text)
session_expires = Column(TIMESTAMP(timezone=True), default="now()")
ctime = Column(TIMESTAMP(timezone=True), nullable=False, default="now()")
claimed_time = Column(TIMESTAMP(timezone=True))
is_admin = Column(Boolean, nullable=False, default=False)
balance = Column(Numeric(precision=35, scale=2),
CheckConstraint("balance >= 0", name="min_balance"),
default=0.0, nullable=False)
pending = Column(Numeric(precision=35, scale=2), default=None)
anonymous = Column(Boolean, default=False, nullable=False)
goal = Column(Numeric(precision=35, scale=2), default=None)
balanced_account_uri = Column(Text)
last_ach_result = Column(Text)
is_suspicious = Column(Boolean)

### Relations ###
accounts_elsewhere = relationship("Elsewhere", backref="participant",
lazy="dynamic")
exchanges = relationship("Exchange", backref="participant")
# TODO: Once tippee/tipper are renamed to tippee_id/tipper_idd, we can go
# ahead and drop the foreign_keys & rename backrefs to tipper/tippee
tipper_in = relationship("Tip", backref="tipper_participant",
foreign_keys="Tip.tipper", lazy="dynamic")
tippee_in = relationship("Tip", backref="tippee_participant",
foreign_keys="Tip.tippee", lazy="dynamic")
transferer = relationship("Transfer", backref="transferer",
foreign_keys="Transfer.tipper")
transferee = relationship("Transfer", backref="transferee",
foreign_keys="Transfer.tippee")

# Class-specific exceptions
class IdTooLong(Exception): pass
class IdContainsInvalidCharacters(Exception): pass
class IdIsRestricted(Exception): pass
class IdAlreadyTaken(Exception): pass

def resolve_unclaimed(self):
if self.accounts_elsewhere:
return self.accounts_elsewhere[0].resolve_unclaimed()
else:
return None

def set_as_claimed(self, claimed_at=None):
if claimed_at is None:
claimed_at = datetime.datetime.now(pytz.utc)
self.claimed_time = claimed_at
db.session.add(self)
db.session.commit()

def change_id(self, desired_id):
"""Raise Response or return None.
We want to be pretty loose with usernames. Unicode is allowed--XXX
aspen bug :(. So are spaces.Control characters aren't. We also limit to
32 characters in length.
"""
for i, c in enumerate(desired_id):
if i == 32:
raise self.IdTooLong # Request Entity Too Large (more or less)
elif ord(c) < 128 and c not in ASCII_ALLOWED_IN_PARTICIPANT_ID:
raise self.IdContainsInvalidCharacters # Yeah, no.
elif c not in ASCII_ALLOWED_IN_PARTICIPANT_ID:
raise self.IdContainsInvalidCharacters # XXX Burned by an Aspen bug. :`-(
# https://github.com/zetaweb/aspen/issues/102

if desired_id in gittip.RESTRICTED_IDS:
raise self.IdIsRestricted

if desired_id != self.id:
# Will raise sqlalchemy.exc.IntegrityError if the desired_id is taken.
try:
self.id = desired_id
db.session.add(self)
db.session.commit()
except IntegrityError as e:
db.session.rollback()
raise self.IdAlreadyTaken

def get_accounts_elsewhere(self):
github_account = twitter_account = None
for account in self.accounts_elsewhere.all():
if account.platform == "github":
github_account = account
elif account.platform == "twitter":
twitter_account = account
return (github_account, twitter_account)

def get_giving_for_profile(self):
return ParticipantClass(self.id).get_giving_for_profile()

def get_tip_to(self, tippee):
tip = self.tipper_in.filter_by(tippee=tippee).first()

if tip:
amount = tip.amount
else:
amount = Decimal('0.0')

return amount

@property
def dollars_giving(self):
return sum(tip.amount for tip in self.tipper_in)

@property
def dollars_receiving(self):
return sum(tip.amount for tip in self.tippee_in)

def get_number_of_backers(self):
nbackers = self.tippee_in\
.distinct("tips.tipper")\
.filter(Participant.last_bill_result == '',\
"participants.is_suspicious IS NOT true")\
.count()
return nbackers

def get_chart_of_receiving(self):
# TODO: Move the query in to this class.
return ParticipantClass(self.id).get_chart_of_receiving()

def get_giving_for_profile(self, db=None):
return ParticipantClass(self.id).get_giving_for_profile(db)

def get_tips_and_total(self, for_payday=False, db=None):
return ParticipantClass(self.id).get_tips_and_total(for_payday, db)

def take_over(self, account_elsewhere, have_confirmation=False):
ParticipantClass(self.id).take_over(account_elsewhere,
have_confirmation)
Loading

0 comments on commit e03cc8a

Please sign in to comment.