Skip to content

Commit

Permalink
Twitter sign in added. Closes Scifabric#14
Browse files Browse the repository at this point in the history
This commit adds support for Twitter OAuth sign in methods. This feature can be
enabled or disabled via the local settings (check the template file).

This commit introduces a change into the DB, so Alembic migration tool has been
used to support the migration of the schema. The migration does not support SQLite
as this DB does not fully support the ALTER TABLE sql instruction (check the documentation
of Alembic).

If you are running an old version of the DB use the alembic command to upgrade to head:

alembic upgrade head

Otherwise create the DB as usual, as the new field for the Twitter account has been
included in the model.
  • Loading branch information
teleyinex committed May 16, 2012
1 parent 5c4b26c commit 67b55df
Show file tree
Hide file tree
Showing 14 changed files with 325 additions and 3 deletions.
45 changes: 45 additions & 0 deletions alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# A generic, single database configuration.

[alembic]
# path to migration scripts
script_location = alembic

# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s

sqlalchemy.url = postgresql://tester:tester@localhost/pybossa

# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
1 change: 1 addition & 0 deletions alembic/README
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Generic single-database configuration.
71 changes: 71 additions & 0 deletions alembic/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from __future__ import with_statement
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config

# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)

# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None

# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.

def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url)

with context.begin_transaction():
context.run_migrations()

def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
engine = engine_from_config(
config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool)

connection = engine.connect()
context.configure(
connection=connection,
target_metadata=target_metadata
)

try:
with context.begin_transaction():
context.run_migrations()
finally:
connection.close()

if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

22 changes: 22 additions & 0 deletions alembic/script.py.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision}
Create Date: ${create_date}

"""

# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}

from alembic import op
import sqlalchemy as sa
${imports if imports else ""}

def upgrade():
${upgrades if upgrades else "pass"}


def downgrade():
${downgrades if downgrades else "pass"}
21 changes: 21 additions & 0 deletions alembic/versions/2fb54e27efed_add_twitter_user_id_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Add twitter_user_id to the Column:User
Revision ID: 2fb54e27efed
Revises: None
Create Date: 2012-05-16 08:43:18.768728
"""

# revision identifiers, used by Alembic.
revision = '2fb54e27efed'
down_revision = None

from alembic import op
import sqlalchemy as sa


def upgrade():
op.add_column('user', sa.Column('twitter_user_id', sa.Integer, unique=True))

def downgrade():
op.drop_column('user', 'twitter_user_id')
11 changes: 11 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,29 @@
import pybossa.model as model
import pybossa.web as web

from alembic.config import Config
from alembic import command

def db_create():
'''Create the db'''
dburi = web.app.config['SQLALCHEMY_DATABASE_URI']
engine = model.create_engine(dburi)
model.Base.metadata.create_all(bind=engine)
# then, load the Alembic configuration and generate the
# version table, "stamping" it with the most recent rev:
alembic_cfg = Config("alembic.ini")
command.stamp(alembic_cfg,"head")

def db_rebuild():
'''Rebuild the db'''
dburi = web.app.config['SQLALCHEMY_DATABASE_URI']
engine = model.create_engine(dburi)
model.Base.metadata.drop_all(bind=engine)
model.Base.metadata.create_all(bind=engine)
# then, load the Alembic configuration and generate the
# version table, "stamping" it with the most recent rev:
alembic_cfg = Config("alembic.ini")
command.stamp(alembic_cfg,"head")

def fixtures():
'''Create some fixtures!'''
Expand Down
2 changes: 2 additions & 0 deletions pybossa/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,8 @@ class User(Base, flaskext.login.UserMixin):
category = Column(Integer)
#: TODO: find out ...
flags = Column(Integer)
# Twitter user_id field
twitter_user_id = Column(Integer, unique=True)
#: arbitrary additional information about the user in a JSON dict.
info = Column(JSONType, default=dict)

Expand Down
9 changes: 9 additions & 0 deletions pybossa/templates/account/login.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
{% extends "base.html" %}

{% block content %}
{% if auth.twitter %}
{% if next is not none%}
<a href="{{ url_for('account.login_twitter', next=next) }}" class="btn btn-info">Sign in with Twitter</a>
{% else %}
<a href="{{ url_for('account.login_twitter') }}" class="btn btn-info">Sign in with Twitter</a>
{% endif %}
<hr>
{% endif %}

{% from "_formhelpers.html" import render_field %}
<form method="post" class="form-horizontal" action="">
{{ render_field(form.username, placeholder="your username") }}
Expand Down
23 changes: 23 additions & 0 deletions pybossa/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from flask import abort, request, make_response, current_app
from functools import wraps
from flaskext.wtf import Form, TextField, PasswordField, validators, ValidationError
from flaskext.oauth import OAuth

def jsonpify(f):
"""Wraps JSONified output for JSONP"""
Expand Down Expand Up @@ -139,3 +140,25 @@ def pretty_date(time=False):
return str(day_diff/30) + " months ago"
return str(day_diff/365) + " years ago"

class Twitter:
oauth = OAuth()
def __init__(self, c_k, c_s):
#oauth = OAuth()
# Use Twitter as example remote application
self.oauth = self.oauth.remote_app('twitter',
# unless absolute urls are used to make requests, this will be added
# before all URLs. This is also true for request_token_url and others.
base_url='http://api.twitter.com/1/',
# where flask should look for new request tokens
request_token_url='http://api.twitter.com/oauth/request_token',
# where flask should exchange the token with the remote application
access_token_url='http://api.twitter.com/oauth/access_token',
# twitter knows two authorizatiom URLs. /authorize and /authenticate.
# they mostly work the same, but for sign on /authenticate is
# expected because this will give the user a slightly different
# user interface on the twitter side.
authorize_url='http://api.twitter.com/oauth/authenticate',
# the consumer keys from the twitter application registry.
consumer_key = c_k, #app.config['TWITTER_CONSUMER_KEY'], #'xBeXxg9lyElUgwZT6AZ0A',
consumer_secret = c_s #app.config['TWITTER_CONSUMER_KEY']#'aawnSpNTOVuDCjx7HMh6uSXetjNN8zWLpZwCEU4LBrk'
)
94 changes: 91 additions & 3 deletions pybossa/view/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,18 @@
# You should have received a copy of the GNU Affero General Public License
# along with PyBOSSA. If not, see <http://www.gnu.org/licenses/>.

from flask import Blueprint, request, url_for, flash, redirect
from flask import Blueprint, request, url_for, flash, redirect, session
from flask import render_template
from flaskext.login import login_required, login_user, logout_user, current_user
from flaskext.wtf import Form, TextField, PasswordField, validators, ValidationError, IntegerField, HiddenInput

import pybossa.model as model
from pybossa.util import Unique
from pybossa.util import Twitter
import settings_local as config

blueprint = Blueprint('account', __name__)


@blueprint.route('/')
def index():
accounts = model.Session.query(model.User).all()
Expand All @@ -49,8 +50,95 @@ def login():

if request.method == 'POST' and not form.validate():
flash('Please correct the errors', 'error')
return render_template('account/login.html', title = "Login", form=form)
auth = {'twitter': False}
if current_user.is_anonymous():
try:
twitter
auth['twitter'] = True
return render_template('account/login.html', title="Login", form=form, auth=auth, next=request.args.get('next'))
except NameError:
return render_template('account/login.html', title="Login", form=form, auth=auth, next=request.args.get('next'))
else:
# User already signed in, so redirect to home page
return redirect(url_for("home"))


try:
twitter = Twitter(config.TWITTER_CONSUMER_KEY, config.TWITTER_CONSUMER_SECRET)
@blueprint.route('/twitter', methods=['GET','POST'])
def login_twitter():
return twitter.oauth.authorize(callback=url_for('.oauth_authorized',
next=request.args.get("next") ))

@twitter.oauth.tokengetter
def get_twitter_token():
if current_user.is_anonymous():
return None
else:
return((current_user.info['twitter_token']['oauth_token'],
current_user.info['twitter_token']['oauth_token_secret']))

@blueprint.route('/oauth-authorized')
@twitter.oauth.authorized_handler
def oauth_authorized(resp):
"""Called after authorization. After this function finished handling,
the OAuth information is removed from the session again. When this
happened, the tokengetter from above is used to retrieve the oauth
token and secret.
Because the remote application could have re-authorized the application
it is necessary to update the values in the database.
If the application redirected back after denying, the response passed
to the function will be `None`. Otherwise a dictionary with the values
the application submitted. Note that Twitter itself does not really
redirect back unless the user clicks on the application name.
"""
next_url = request.args.get('next') or url_for('home')
if resp is None:
flash(u'You denied the request to sign in.', 'error')
return redirect(next_url)

user = model.Session.query(model.User).filter_by(twitter_user_id = resp['user_id']).first()

# user never signed on
# Twitter API does not provide a way to get the e-mail so we will ask for it
# only the first time
request_email = False
first_login = False
if user is None:
request_email = True
first_login = True
twitter_token = dict(
oauth_token = resp['oauth_token'],
oauth_token_secret = resp['oauth_token_secret']
)
info = dict(twitter_token = twitter_token)
user = model.User(
fullname = resp['screen_name'],
name = resp['screen_name'],
email_addr = 'None',
twitter_user_id = resp['user_id'],
info = info
)
model.Session.add(user)
model.Session.commit()

login_user(user, remember=True)
flash("Welcome back %s" % user.fullname, 'success')
if (user.email_addr == "None"): request_email = True

if request_email:
if first_login:
flash("This is your first login, please add a valid e-mail")
else:
flash("Please update your e-mail address in your profile page")
return redirect(url_for('.update_profile'))

return redirect(next_url)
except:
print "Twitter CONSUMER_KEY and CONSUMER_SECRET not available in the config file"
print "Twitter login disabled"

@blueprint.route('/logout')
def logout():
Expand Down
2 changes: 2 additions & 0 deletions pybossa/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ def home():
'taskrun': 0,
'user': 0
}
if current_user.is_authenticated() and current_user.email_addr == "None":
flash("Please update your e-mail address in your profile page, right now it is empty!")
return render_template('/home/index.html', stats=stats)

if __name__ == "__main__":
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ Flask-Login
Flask-WTF
Flask-Gravatar
dateutils
alembic
5 changes: 5 additions & 0 deletions settings_local.py.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ TITLE = 'PyBossa'
COPYRIGHT = 'Set Your Institution'
DESCRIPTION = 'Set the description in your config'

## External Auth providers
#TWITTER_CONSUMER_KEY = ''
#TWITTER_CONSUMER_SECRET = ''


## list of administrator emails to which error emails get sent
# ADMINS = ['[email protected]']

Expand Down
Loading

0 comments on commit 67b55df

Please sign in to comment.