Merge pull request #1 from joshuamckenty/master

Basic flask app with OpenId-based auth and a few models behind an admin view.
This commit is contained in:
David Lenwell 2013-09-03 16:01:23 -07:00
commit 87573616de
25 changed files with 829 additions and 0 deletions

4
.gitignore vendored
View File

@ -33,3 +33,7 @@ nosetests.xml
.mr.developer.cfg
.project
.pydevproject
*.db
*.DS_Store
manifest.yml

1
Procfile Normal file
View File

@ -0,0 +1 @@
web: gunicorn refstack.web:app

View File

@ -6,3 +6,7 @@ Vendor-facing API for registration of interop-compliance endpoints and credentia
Running at http://refstack.org
See (living) documentation at https://etherpad.openstack.org/RefStackBlueprint
http://ci.openstack.org/stackforge.html
http://ci.openstack.org/devstack-gate.html
https://github.com/openstack-infra/config/
http://pythonhosted.org/WSME/index.html

50
alembic.ini Normal file
View File

@ -0,0 +1,50 @@
# 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
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
sqlalchemy.url = driver://user:pass@localhost/dbname
# 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
alembic/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

79
alembic/env.py Normal file
View File

@ -0,0 +1,79 @@
from __future__ import with_statement
import os
import sys
sys.path.append("./")
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
from refstack.web import app
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
cur_db_uri = config.get_section_option('alembic', 'sqlalchemy.url')
my_db_uri = app.config.get('SQLALCHEMY_DATABASE_URI', cur_db_uri)
config.set_section_option('alembic', 'sqlalchemy.url', my_db_uri)
# 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
from refstack.web import db
target_metadata = db.metadata
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
alembic/script.py.mako Normal file
View File

@ -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"}

View File

@ -0,0 +1,33 @@
"""First step
Revision ID: 2e26571834ea
Revises: None
Create Date: 2013-06-30 15:07:31.746984
"""
# revision identifiers, used by Alembic.
revision = '2e26571834ea'
down_revision = None
from alembic import op
import sqlalchemy as sa
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('vendor',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('vendor_name', sa.String(length=80), nullable=True),
sa.Column('contact_email', sa.String(length=120), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('contact_email'),
sa.UniqueConstraint('vendor_name')
)
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_table('vendor')
### end Alembic commands ###

View File

@ -0,0 +1,52 @@
"""Adding Cloud and User models
Revision ID: 40d4c6d389ec
Revises: 2e26571834ea
Create Date: 2013-07-02 15:02:46.951119
"""
# revision identifiers, used by Alembic.
revision = '40d4c6d389ec'
down_revision = '2e26571834ea'
from alembic import op
import sqlalchemy as sa
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=60), nullable=True),
sa.Column('email', sa.String(length=200), nullable=True),
sa.Column('openid', sa.String(length=200), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('openid')
)
op.create_table('cloud',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('vendor_id', sa.Integer(), nullable=True),
sa.Column('endpoint', sa.String(length=120), nullable=True),
sa.Column('test_user', sa.String(length=80), nullable=True),
sa.Column('test_key', sa.String(length=80), nullable=True),
sa.Column('admin_endpoint', sa.String(length=120), nullable=True),
sa.Column('admin_user', sa.String(length=80), nullable=True),
sa.Column('admin_key', sa.String(length=80), nullable=True),
sa.ForeignKeyConstraint(['vendor_id'], ['vendor.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('admin_endpoint'),
sa.UniqueConstraint('admin_key'),
sa.UniqueConstraint('admin_user'),
sa.UniqueConstraint('endpoint'),
sa.UniqueConstraint('test_key'),
sa.UniqueConstraint('test_user')
)
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_table('cloud')
op.drop_table('user')
### end Alembic commands ###

23
fabfile.py vendored Normal file
View File

@ -0,0 +1,23 @@
from fabric.api import env, roles, run, cd
# Define sets of servers as roles
env.roledefs = {
'web': ['refstack.org'],
}
# Set the user to use for ssh
env.user = 'refstack'
@roles('web')
def get_version():
run('cat /etc/issue')
@roles('web')
def deploy():
with cd('/var/www/refstack'):
run('git checkout master')
run('git pull')
run('sudo pip install -r requirements.txt')
run('alembic upgrade head')
run('sudo uwsgi --reload /tmp/project-master_refstack.pid')

3
refstack.wsgi Normal file
View File

@ -0,0 +1,3 @@
import os
# os.environ['YOURAPPLICATION_CONFIG'] = '/var/www/yourapplication/application.cfg'
from refstack import web

0
refstack/__init__.py Normal file
View File

3
refstack/runserver.py Normal file
View File

@ -0,0 +1,3 @@
from web import app
app.run()

3
refstack/schema.sql Normal file
View File

@ -0,0 +1,3 @@
CREATE TABLE IF NOT EXISTS VENDORS
( vendor_id integer primary key asc autoincrement,
vendor_name TEXT NOT NULL);

BIN
refstack/static/openid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 B

View File

@ -0,0 +1,47 @@
body {
font-family: 'Georgia', serif;
font-size: 16px;
margin: 30px;
padding: 0;
}
a {
color: #335E79;
}
p.message {
color: #335E79;
padding: 10px;
background: #CADEEB;
}
p.error {
color: #783232;
padding: 10px;
background: #EBCACA;
}
.navigation li { display: inline;}
input {
font-family: 'Georgia', serif;
font-size: 16px;
border: 1px solid black;
color: #335E79;
padding: 2px;
}
input[type="submit"] {
background: #CADEEB;
color: #335E79;
border-color: #335E79;
}
input[name="openid"] {
background: url(openid.png) 4px no-repeat;
padding-left: 24px;
}
h1, h2 {
font-weight: normal;
}

152
refstack/static/toast.css Normal file
View File

@ -0,0 +1,152 @@
/*-----------------------------------*\
Toast
A Simple CSS Framework
=================================
Values you may want to change:
- .container { max-width:; }
- p { margin-bottom:; }
- html { font:; }
Remember: no framework will be as
good as a custom built, per-
project one. Toast and other
frameworks are best used for
rapid prototyping.
\*-----------------------------------*/
/*-----------------------------------*\
$RESET
\*-----------------------------------*/
* {
margin: 0;
padding: 0;
position: relative;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
/*-----------------------------------*\
$GRID
\*-----------------------------------*/
.container {
/* Whatever you want. Theyre your oats. */
max-width: 960px;
margin: 0 auto;
padding: 0 30px;
padding: 0 1.5rem;
}
.grid {
margin-left: -3%;
max-width: 105%;
}
.unit {
display: inline-block;
*display: inline;
*zoom: 1;
vertical-align: top;
margin-left: 3%;
margin-right: -.25em;
/* Clearfix */
overflow: hidden;
*overflow: visible;
}
.unit.demo {
background-color: #fff8eb;
height: 48px;
height: 3rem;
margin-bottom: 24px;
margin-bottom: 1.5rem;
}
.span-grid {
width: 97%;
}
.one-of-two { width: 47%; }
.one-of-three { width: 30.36%; }
.two-of-three { width: 63.666666666%; }
.one-of-four { width: 22.05%; }
.three-of-four { width: 72%; }
.one-of-five { width: 17.07%; }
.two-of-five { width: 37%; }
.three-of-five { width: 57%; }
.four-of-five { width: 77%; }
@media screen and (max-width: 650px) {
.grid {
margin-left: 0;
max-width: none;
}
.unit {
width: auto;
margin-left: 0;
display: block;
}
}
/*-----------------------------------*\
$TYPE
Works off the assumption of a 1.5
line height @ 20px. Again, change
as necessary.
\*-----------------------------------*/
p, .p, ul, ol, hr, table, form, pre,
h1, .alpha, h2, .beta {
margin-bottom: 30px;
margin-bottom: 1.5rem;
}
h1, .alpha {
font-size: 60px;
font-size: 3rem;
font-weight: 700;
line-height: 1;
}
h2, .beta {
font-size: 30px;
font-size: 1.5rem;
font-weight: 400;
line-height: 2;
}
h3, .gamma {
font-size: 20px;
font-size: 1rem;
font-weight: 700;
}
hr {
border: none;
border-bottom: 1px solid rgba(0,0,0,.1);
margin-top: -1px;
}
/*-----------------------------------*\
$MAIN
\*-----------------------------------*/
html {
font: 125%/1.5 Helvetica Neue, Helvetica, Arial, sans-serif;
}
@media screen and (max-width: 650px) {
html {
font-size: 100%;
}
}

View File

@ -0,0 +1,22 @@
{% extends "layout.html" %}
{% block title %}Create Profile{% endblock %}
{% block body %}
<h2>Create Profile</h2>
<p>
Hey! This is the first time you signed in on this website. In
order to proceed we need some extra information from you:
<form action="" method=post>
<dl>
<dt>Name:
<dd><input type=text name=name size=30 value="{{ request.values.name }}">
<dt>E-Mail:
<dd><input type=text name=email size=30 value="{{ request.values.email }}">
</dl>
<p>
<input type=submit value="Create profile">
<input type=hidden name=next value="{{ next }}">
</form>
<p>
If you don't want to proceed, you can <a href="{{ url_for('logout')
}}">sign out</a> again.
{% endblock %}

View File

@ -0,0 +1,16 @@
{% extends "layout.html" %}
{% block title %}Edit Profile{% endblock %}
{% block body %}
<h2>Edit Profile</h2>
<form action="" method=post>
<dl>
<dt>Name:
<dd><input type=text name=name size=30 value="{{ form.name }}">
<dt>E-Mail
<dd><input type=text name=email size=30 value="{{ form.email }}">
</dl>
<p>
<input type=submit value="Update profile">
<input type=submit name=delete value="Delete">
</form>
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends "layout.html" %}
{% block title %}Welcome{% endblock %}
{% block body %}
<div class="unit span-grid">
<h1>What is Refstack?</h1>
</div>
<div class="unit one-of-three">
An existence proof of the certified openstack APIs.
</div>
<div class="unit one-of-three">
A reference OpenStack environment for tools developers.
</div>
<div class="unit one-of-three">
A certification process for OpenStack service and product vendors.
</div>
<div class="unit span-grid">
Current vendors registered with RefStack are:<br/>
<ul>
{% for vendor in vendors %}
<li><a href="mailto:{{ vendor.contact_email }}">{{ vendor.vendor_name }}</a></li>
{% endfor %}
</ul>
</div>
<div class="unit span-grid">
For detailed specification, see <a href="https://etherpad.openstack.org/RefStackBlueprint">https://etherpad.openstack.org/RefStackBlueprint</a>
</div>
{% endblock %}

View File

@ -0,0 +1,36 @@
<!doctype html>
<head>
<title>{% block title %}Welcome{% endblock %} | RefStack</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static',
filename='refstack.css') }}">
<link rel="stylesheet" type="text/css" href="/static/toast.css">
</head>
<body>
<div class="container">
<div class="grid">
<div class="unit one-of-two"> </div>
<div class="unit one-of-two">
<ul class="navigation">
<li><a href="{{ url_for('index') }}">overview</a>
{% if g.user %}
<li><a href="{{ url_for('edit_profile') }}">profile</a>
<li><a href="{{ url_for('logout') }}">sign out [{{ g.user.name }}]</a>
{% else %}
<li><a href="{{ url_for('login') }}">sign in</a>
{% endif %}
</ul>
</div>
{% for message in get_flashed_messages() %}
<p class=message>{{ message }}
{% endfor %}
{% block body %}{% endblock %}
</div>
</div>
</body></html>

View File

@ -0,0 +1,13 @@
{% extends "layout.html" %}
{% block title %}Sign in{% endblock %}
{% block body %}
<h2>Sign in</h2>
<form action="" method=post>
{% if error %}<p class=error><strong>Error:</strong> {{ error }}</p>{% endif %}
<p>
OpenID:
<input type=text name=openid size=30>
<input type=submit value="Sign in">
<input type=hidden name=next value="{{ next }}">
</form>
{% endblock %}

210
refstack/web.py Normal file
View File

@ -0,0 +1,210 @@
# LICENSE HERE
"""
Simple Refstack website.
"""
import os
import random
import sqlite3
import sys
from flask import Flask, abort, flash, request, redirect, url_for, render_template, g, session
from flask_openid import OpenID
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext.admin import Admin, BaseView, expose, AdminIndexView
from flask.ext.admin.contrib.sqlamodel import ModelView
from sqlalchemy.exc import IntegrityError
from flask.ext.security import Security, SQLAlchemyUserDatastore, \
UserMixin, RoleMixin, login_required
from wtforms import Form, BooleanField, TextField, PasswordField, validators
from flask_mail import Mail
import requests
app = Flask(__name__)
app.config['MAILGUN_KEY'] = 'key-7o9l9dupikfpsdvqi0ewot-se8g1hz64'
app.config['MAILGUN_DOMAIN'] = 'hastwoparents.com'
app.config['SECRET_KEY'] = 'GIANT_UGLY-SECRET-GOES-H3r3'
db_path = os.path.abspath(os.path.join(os.path.basename(__file__), "../"))
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///%s/refstack.db' % (db_path))
app.config['DEBUG'] = True
app.config['SECURITY_PASSWORD_HASH'] = 'sha512_crypt'
app.config['SECURITY_PASSWORD_SALT'] = app.config['SECRET_KEY']
app.config['SECURITY_POST_LOGIN_VIEW'] = 'dashboard'
app.config['SECURITY_RECOVERABLE'] = True
app.config['SECURITY_REGISTERABLE'] = True
app.config['SECURITY_EMAIL_SENDER'] = "admin@hastwoparents.com"
app.config['MAIL_SERVER'] = 'smtp.mailgun.org'
app.config['MAIL_PORT'] = 465
app.config['MAIL_USE_SSL'] = True
app.config['MAIL_USERNAME'] = 'postmaster@hastwoparents.com'
app.config['MAIL_PASSWORD'] = '0sx00qlvqbo3'
mail = Mail(app)
# setup flask-openid
oid = OpenID(app)
db = SQLAlchemy(app)
class Vendor(db.Model):
id = db.Column(db.Integer, primary_key=True)
vendor_name = db.Column(db.String(80), unique=True)
contact_email = db.Column(db.String(120), unique=True)
def __str__(self):
return self.vendor_name
class Cloud(db.Model):
id = db.Column(db.Integer, primary_key=True)
vendor_id = db.Column(db.Integer, db.ForeignKey('vendor.id'))
vendor = db.relationship('Vendor',
backref=db.backref('clouds', lazy='dynamic'))
endpoint = db.Column(db.String(120), unique=True)
test_user = db.Column(db.String(80), unique=True)
test_key = db.Column(db.String(80), unique=True)
admin_endpoint = db.Column(db.String(120), unique=True)
admin_user = db.Column(db.String(80), unique=True)
admin_key = db.Column(db.String(80), unique=True)
def __str__(self):
return self.endpoint
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(60))
email = db.Column(db.String(200))
openid = db.Column(db.String(200), unique=True)
def __init__(self, name, email, openid):
self.name = name
self.email = email
self.openid = openid
def __str__(self):
return self.name
# IndexView = AdminIndexView(url="/")
admin = Admin(app) #, index_view=IndexView)
class SecureView(ModelView):
def is_accessible(self):
return g.user is not None
admin.add_view(SecureView(Vendor, db.session))
admin.add_view(SecureView(Cloud, db.session))
admin.add_view(SecureView(User, db.session))
@app.before_request
def before_request():
g.user = None
if 'openid' in session:
g.user = User.query.filter_by(openid=session['openid']).first()
@app.route('/', methods=['POST','GET'])
def index():
vendors = Vendor.query.all()
return render_template('index.html', vendors = vendors)
@app.route('/login', methods=['GET', 'POST'])
@oid.loginhandler
def login():
"""Does the login via OpenID. Has to call into `oid.try_login`
to start the OpenID machinery.
"""
# if we are already logged in, go back to were we came from
if g.user is not None:
return redirect(oid.get_next_url())
return oid.try_login("https://login.launchpad.net/", ask_for=['email', 'nickname'])
# return render_template('login.html', next=oid.get_next_url(),
# error=oid.fetch_error())
@oid.after_login
def create_or_login(resp):
"""This is called when login with OpenID succeeded and it's not
necessary to figure out if this is the users's first login or not.
This function has to redirect otherwise the user will be presented
with a terrible URL which we certainly don't want.
"""
session['openid'] = resp.identity_url
user = User.query.filter_by(openid=resp.identity_url).first()
if user is not None:
flash(u'Successfully signed in')
g.user = user
return redirect(oid.get_next_url())
return redirect(url_for('create_profile', next=oid.get_next_url(),
name=resp.fullname or resp.nickname,
email=resp.email))
@app.route('/create-profile', methods=['GET', 'POST'])
def create_profile():
"""If this is the user's first login, the create_or_login function
will redirect here so that the user can set up his profile.
"""
if g.user is not None or 'openid' not in session:
return redirect(url_for('index'))
if request.method == 'POST':
name = request.form['name']
email = request.form['email']
if not name:
flash(u'Error: you have to provide a name')
elif '@' not in email:
flash(u'Error: you have to enter a valid email address')
else:
flash(u'Profile successfully created')
db.session.add(User(name, email, session['openid']))
db.session.commit()
return redirect(oid.get_next_url())
return render_template('create_profile.html', next_url=oid.get_next_url())
@app.route('/profile', methods=['GET', 'POST'])
def edit_profile():
"""Updates a profile"""
if g.user is None:
abort(401)
form = dict(name=g.user.name, email=g.user.email)
if request.method == 'POST':
if 'delete' in request.form:
db.session.delete(g.user)
db.session.commit()
session['openid'] = None
flash(u'Profile deleted')
return redirect(url_for('index'))
form['name'] = request.form['name']
form['email'] = request.form['email']
if not form['name']:
flash(u'Error: you have to provide a name')
elif '@' not in form['email']:
flash(u'Error: you have to enter a valid email address')
else:
flash(u'Profile successfully created')
g.user.name = form['name']
g.user.email = form['email']
db.session.commit()
return redirect(url_for('edit_profile'))
return render_template('edit_profile.html', form=form)
@app.route('/logout')
def logout():
session.pop('openid', None)
flash(u'You have been signed out')
return redirect(oid.get_next_url())
if __name__ == '__main__':
app.logger.setLevel('DEBUG')
port = int(os.environ.get('PORT', 5000))
app.run(host='0.0.0.0', port=port, debug=True)

19
requirements.txt Normal file
View File

@ -0,0 +1,19 @@
Flask==0.9
Flask-Admin==1.0.6
Flask-Login==0.1.3
Flask-Mail==0.8.2
Flask-OpenID==1.1.1
Flask-Principal==0.3.5
Flask-SQLAlchemy==0.16
Flask-Security==1.6.3
Flask-WTF==0.8.3
SQLAlchemy==0.8.1
WTForms==1.0.4
Werkzeug==0.8.3
alembic==0.5.0
gunicorn==0.17.4
psycopg2==2.5
pyOpenSSL==0.13
pycrypto==2.6
python-openid==2.2.5
requests==1.2.3

9
uwsgi.ini Normal file
View File

@ -0,0 +1,9 @@
[uwsgi]
module=refstack.web:app
socket=/tmp/uwsgi_refstack.sock
#master=False
master=True
pidfile=/tmp/project-master_refstack.pid
vacuum=True
max-requests=5000
daemonize=/tmp/refstack.log