Admin interface for snippets

This commit is contained in:
Armin Ronacher 2010-05-15 23:19:13 +02:00
parent 45df60cfc6
commit 53ce827b17
15 changed files with 214 additions and 83 deletions

View file

@ -1,4 +1,5 @@
from flask import Flask, session, g, render_template from flask import Flask, session, g, render_template
from flaskext.openid import OpenID
import websiteconfig as config import websiteconfig as config
@ -6,6 +7,9 @@ app = Flask(__name__)
app.debug = config.DEBUG app.debug = config.DEBUG
app.secret_key = config.SECRET_KEY app.secret_key = config.SECRET_KEY
from flask_website.openid_auth import DatabaseOpenIDStore
oid = OpenID(store_factory=DatabaseOpenIDStore)
@app.errorhandler(404) @app.errorhandler(404)
def not_found(error): def not_found(error):
return render_template('404.html'), 404 return render_template('404.html'), 404

View file

@ -32,6 +32,10 @@ class User(Model):
self.name = name self.name = name
self.openid = openid self.openid = openid
@property
def is_admin(self):
return self.openid in config.ADMINS
def __eq__(self, other): def __eq__(self, other):
return type(self) is type(other) and self.id == other.id return type(self) is type(other) and self.id == other.id

View file

@ -1,23 +1,14 @@
from time import time from time import time
from hashlib import sha1
from openid.association import Association from openid.association import Association
from openid.store.interface import OpenIDStore from openid.store.interface import OpenIDStore
from openid.consumer.consumer import Consumer, SUCCESS, CANCEL
from openid.consumer import discover
from openid.store import nonce from openid.store import nonce
# python-openid is a really stupid library in that regard, we have
# to disable logging by monkey patching
from openid import oidutil
oidutil.log = lambda *a, **kw: None
from flask import request, redirect, abort, url_for, flash, session
from flask_website.database import User, db_session, OpenIDAssociation, \ from flask_website.database import User, db_session, OpenIDAssociation, \
OpenIDUserNonce OpenIDUserNonce
class WebsiteOpenIDStore(OpenIDStore): class DatabaseOpenIDStore(OpenIDStore):
"""Implements the open store for the website using the database.""" """Implements the open store for the website using the database."""
def storeAssociation(self, server_url, association): def storeAssociation(self, server_url, association):
@ -30,6 +21,7 @@ class WebsiteOpenIDStore(OpenIDStore):
assoc_type=association.assoc_type assoc_type=association.assoc_type
) )
db_session.add(assoc) db_session.add(assoc)
db_session.commit()
def getAssociation(self, server_url, handle=None): def getAssociation(self, server_url, handle=None):
q = OpenIDAssociation.query.filter_by(server_url=server_url) q = OpenIDAssociation.query.filter_by(server_url=server_url)
@ -46,10 +38,13 @@ class WebsiteOpenIDStore(OpenIDStore):
return result_assoc return result_assoc
def removeAssociation(self, server_url, handle): def removeAssociation(self, server_url, handle):
return OpenIDAssociation.query.filter( try:
(OpenIDAssociation.server_url == server_url) & return OpenIDAssociation.query.filter(
(OpenIDAssociation.handle == handle) (OpenIDAssociation.server_url == server_url) &
).delete() (OpenIDAssociation.handle == handle)
).delete()
finally:
db_session.commit()
def useNonce(self, server_url, timestamp, salt): def useNonce(self, server_url, timestamp, salt):
if abs(timestamp - time()) > nonce.SKEW: if abs(timestamp - time()) > nonce.SKEW:
@ -64,64 +59,21 @@ class WebsiteOpenIDStore(OpenIDStore):
rv = OpenIDUserNonce(server_url=server_url, timestamp=timestamp, rv = OpenIDUserNonce(server_url=server_url, timestamp=timestamp,
salt=salt) salt=salt)
db_session.add(rv) db_session.add(rv)
db_session.commit()
return True return True
def cleanupNonces(self): def cleanupNonces(self):
return OpenIDUserNonce.query.filter( try:
OpenIDUserNonce.timestamp <= int(time() - nonce.SKEW) return OpenIDUserNonce.query.filter(
).delete() OpenIDUserNonce.timestamp <= int(time() - nonce.SKEW)
).delete()
finally:
db_session.commit()
def cleanupAssociations(self): def cleanupAssociations(self):
return OpenIDAssociation.query.filter(
OpenIDAssociation.lifetime < int(time())
).delete()
def redirect_back():
return redirect(request.values.get('next') or url_for('general.index'))
def check_return_from_provider():
if request.args.get('openid_complete') != u'yes':
return
try:
consumer = Consumer(session, WebsiteOpenIDStore())
openid_response = consumer.complete(request.args.to_dict(),
url_for('general.login',
_external=True))
if openid_response.status == SUCCESS:
return create_or_login(openid_response.identity_url)
elif openid_response.status == CANCEL:
flash(u'Error: The request was cancelled')
return redirect(url_for('general.login'))
flash(u'Error: OpenID authentication error')
return redirect(url_for('general.login'))
finally:
db_session.commit()
def create_or_login(identity_url):
session['openid'] = identity_url
user = User.query.filter_by(openid=identity_url).first()
if user is None:
next_url = request.values.get('next')
return redirect(url_for('general.first_login', next=next_url))
flash(u'Successfully logged in')
return redirect_back()
def login(identity_url):
try:
try: try:
consumer = Consumer(session, WebsiteOpenIDStore()) return OpenIDAssociation.query.filter(
auth_request = consumer.begin(identity_url) OpenIDAssociation.lifetime < int(time())
except discover.DiscoveryFailure: ).delete()
flash(u'Error: The OpenID was invalid') finally:
return redirect(url_for('general.login')) db_session.commit()
trust_root = request.host_url
next_url = request.values.get('next') or url_for('general.index')
redirect_to = url_for('general.login', openid_complete='yes',
next=next_url, _external=True)
return redirect(auth_request.redirectURL(trust_root, redirect_to))
finally:
db_session.commit()

View file

@ -25,6 +25,14 @@ blockquote { margin: 0; font-style: italic; color: #444; }
margin: 5px 0 0 0; font-size: 0.9em; } margin: 5px 0 0 0; font-size: 0.9em; }
.message { background: #DEEBF3; color: #004B6B; padding: 5px 30px; .message { background: #DEEBF3; color: #004B6B; padding: 5px 30px;
margin: 10px -30px; } margin: 10px -30px; }
.actions { margin-top: 0; }
table { border: 1px solid black; border-collapse: collapse;
margin: 15px 0; }
td, th { border: 1px solid black; padding: 4px 10px;
text-align: left; }
th { background: #eee; font-weight: normal; }
td input { border: none; padding: 0; }
/* forms */ /* forms */
input, textarea, select { border: 1px solid black; padding: 2px; background: white; input, textarea, select { border: 1px solid black; padding: 2px; background: white;

View file

@ -18,9 +18,10 @@
site. Choose wisely because you cannot change this value later: site. Choose wisely because you cannot change this value later:
<dl> <dl>
<dt>Name: <dt>Name:
<dd><input type=text name=name size=30> <dd><input type=text name=name value="{{ request.values.name }}" size=30>
</dl> </dl>
<p> <p>
<input type=hidden name=next value="{{ next }}">
<input type=submit value=Continue> <input type=submit value=Continue>
<input type=submit name=cancel value=Cancel> <input type=submit name=cancel value=Cancel>
</form> </form>

View file

@ -16,7 +16,7 @@
<p> <p>
OpenID URL: OpenID URL:
<input type=text name=openid class=openid size=30> <input type=text name=openid class=openid size=30>
<input type=hidden name=next value="{{ request.values.next or request.referrer or url_for('general.index') }}"> <input type=hidden name=next value="{{ next }}">
<input type=submit value=Login> <input type=submit value=Login>
</form> </form>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "snippets/layout.html" %}
{% block title %}Snippets Archive{% endblock %}
{% block body %}
<h2>Delete Category “{{ category.name }}”</h2>
<p>
Do you want to delete this category? And if yes, what should
happen with the snippets in the category?
<form action="" method=post>
<p>
<select name=move_to>
<option value="">Delete Snippets</option>
{%- for category in other_categories %}
<option value={{ category.id }}>Move to “{{ category.name }}”</option>
{%- endfor %}
</select>
<p>
<input type=submit value=Delete>
<input type=submit name=cancel value=Cancel>
</form>
{% endblock %}

View file

@ -17,13 +17,17 @@
{% endif %} {% endif %}
<p> <p>
Want to share something? Then add a Want to share something? Then add a
<a href="{{ url_for('snippets.new') }}">new snippet</a>. <a href="{{ url_for('new') }}">new snippet</a>.
<h2>Snippets by Category</h2> <h2>Snippets by Category</h2>
<ul> <ul>
{% for category in categories %} {% for category in categories %}
<li><a href="{{ category.url }}">{{ category.name }}</a> ({{ category.count }}) <li><a href="{{ category.url }}">{{ category.name }}</a> ({{ category.count }})
{% endfor %} {% endfor %}
</ul> </ul>
{% if g.user.is_admin %}
<p>
<a href="{{ url_for('manage_categories') }}">manage categories</a>
{% endif %}
{% if recent %} {% if recent %}
<h2>Recently Added</h2> <h2>Recently Added</h2>
<ul> <ul>

View file

@ -0,0 +1,30 @@
{% extends "snippets/layout.html" %}
{% block title %}Snippets Archive{% endblock %}
{% block body %}
<h2>Manage Categories</h2>
{% if categories %}
<form action="" method=post>
<table>
<tr>
<th>Name
<th>Slug
<th>#
<th>?
{% for category in categories %}
<tr>
<td><input type=text name=name.{{ category.id }} value="{{ category.name }}" size=24>
<td><input type=text name=slug.{{ category.id }} value="{{ category.slug }}" size=17>
<td>{{ category.snippets.count() }}
<td><a href="{{ url_for('delete_category', id=category.id) }}">delete</a>
{% endfor %}
</table>
<p class=actions>
<input type=submit value="Update categories">
</form>
{% endif %}
<form action="{{ url_for('new_category') }}" method=post>
<p>Or create a new category:
<input type=text name=name size=30>
<input type=submit value=Create>
</form>
{% endblock %}

View file

@ -8,7 +8,7 @@
<h2>{{ snippet.title }}</h2> <h2>{{ snippet.title }}</h2>
<p class=snippet-author>By {{ snippet.author.name }} <p class=snippet-author>By {{ snippet.author.name }}
filed in <a href="{{ snippet.category.url }}">{{ snippet.category.name }}</a> filed in <a href="{{ snippet.category.url }}">{{ snippet.category.name }}</a>
{% if snippet.author == g.user %} {% if snippet.author == g.user or g.user.is_admin %}
(<a href="{{ url_for('snippets.edit', id=snippet.id) }}">edit</a>) (<a href="{{ url_for('snippets.edit', id=snippet.id) }}">edit</a>)
{% endif %} {% endif %}
{{ snippet.rendered_body }} {{ snippet.rendered_body }}

View file

@ -7,7 +7,7 @@ from pygments import highlight
from pygments.formatters import HtmlFormatter from pygments.formatters import HtmlFormatter
from pygments.lexers import get_lexer_by_name from pygments.lexers import get_lexer_by_name
from pygments.util import ClassNotFound from pygments.util import ClassNotFound
from flask import g, url_for, flash, request, redirect, Markup from flask import g, url_for, flash, abort, request, redirect, Markup
from flask_website.flaskystyle import FlaskyStyle # same as docs from flask_website.flaskystyle import FlaskyStyle # same as docs
from flask_website.database import User from flask_website.database import User
@ -85,3 +85,12 @@ def requires_login(f):
return redirect(url_for('general.login', next=request.path)) return redirect(url_for('general.login', next=request.path))
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function return decorated_function
def requires_admin(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not g.user.is_admin:
abort(401)
return f(*args, **kwargs)
return requires_login(decorated_function)

View file

@ -46,6 +46,32 @@ database = [
''', ''',
bitbucket='leafstorm/flask-xml-rpc', bitbucket='leafstorm/flask-xml-rpc',
docs='http://packages.python.org/Flask-XML-RPC/' docs='http://packages.python.org/Flask-XML-RPC/'
),
Extension('flask-csrf', 'Steve Losh',
description='''
<p>A small Flask extension for adding
<a href=http://en.wikipedia.org/wiki/CSRF>CSRF</a> protection.
''',
docs='http://sjl.bitbucket.org/flask-csrf/',
bitbucket='sjl/flask-csrf'
),
Extension('flask-lesscss', 'Steve Losh',
description='''
<p>
A small Flask extension that makes it easy to use
<a href=http://lesscss.org/>LessCSS</a> with your
Flask application.
''',
docs='http://sjl.bitbucket.org/flask-lesscss/',
bitbucket='sjl/flask-lesscss'
),
Extension('flask-urls', 'Steve Losh',
description='''
<p>
A collection of URL-related functions for Flask applications.
''',
docs='http://sjl.bitbucket.org/flask-urls/',
bitbucket='sjl/flask-urls'
) )
] ]
database.sort(key=lambda x: x.name.lower()) database.sort(key=lambda x: x.name.lower())

View file

@ -1,6 +1,6 @@
from flask import Module, render_template, session, redirect, url_for, \ from flask import Module, render_template, session, redirect, url_for, \
request, flash, g, Response request, flash, g, Response
from flask_website import openid_auth from flask_website import oid
from flask_website.database import db_session, User from flask_website.database import db_session, User
general = Module(__name__) general = Module(__name__)
@ -20,17 +20,30 @@ def logout():
@general.route('/login/', methods=['GET', 'POST']) @general.route('/login/', methods=['GET', 'POST'])
@oid.loginhandler
def login(): def login():
if g.user is not None: if g.user is not None:
return redirect(url_for('general.index')) return redirect(url_for('general.index'))
rv = openid_auth.check_return_from_provider()
if rv is not None:
return rv
if request.method == 'POST': if request.method == 'POST':
openid = request.values.get('openid') openid = request.values.get('openid')
if openid: if openid:
return openid_auth.login(openid) return oid.try_login(openid, ask_for=['fullname', 'nickname'])
return render_template('general/login.html') error = oid.fetch_error()
if error:
flash(u'Error: ' + error)
return render_template('general/login.html', next=oid.get_next_url())
@oid.after_login
def create_or_login(resp):
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('first_login', next=oid.get_next_url(),
name=resp.fullname or resp.nickname))
@general.route('/first-login/', methods=['GET', 'POST']) @general.route('/first-login/', methods=['GET', 'POST'])
@ -45,6 +58,7 @@ def first_login():
db_session.add(User(request.form['name'], session['openid'])) db_session.add(User(request.form['name'], session['openid']))
db_session.commit() db_session.commit()
flash(u'Successfully created profile and logged in') flash(u'Successfully created profile and logged in')
return openid_auth.redirect_back() return redirect(oid.get_next_url())
return render_template('general/first_login.html', return render_template('general/first_login.html',
next=oid.get_next_url(),
openid=session['openid']) openid=session['openid'])

View file

@ -3,7 +3,7 @@ from urlparse import urljoin
from flask import Module, render_template, request, flash, abort, redirect, \ from flask import Module, render_template, request, flash, abort, redirect, \
g, url_for g, url_for
from werkzeug.contrib.atom import AtomFeed from werkzeug.contrib.atom import AtomFeed
from flask_website.utils import requires_login, format_creole from flask_website.utils import requires_login, requires_admin, format_creole
from flask_website.database import Category, Snippet, Comment, db_session from flask_website.database import Category, Snippet, Comment, db_session
snippets = Module(__name__, url_prefix='/snippets') snippets = Module(__name__, url_prefix='/snippets')
@ -71,7 +71,7 @@ def edit(id):
snippet = Snippet.query.get(id) snippet = Snippet.query.get(id)
if snippet is None: if snippet is None:
abort(404) abort(404)
if snippet.author != g.user: if g.user is None or (not g.user.is_admin and snippet.author != g.user):
abort(401) abort(401)
preview = None preview = None
form = dict(title=snippet.title, body=snippet.body, form = dict(title=snippet.title, body=snippet.body,
@ -117,6 +117,64 @@ def category(slug):
snippets=snippets) snippets=snippets)
@snippets.route('/manage-categories/', methods=['GET', 'POST'])
@requires_admin
def manage_categories():
categories = Category.query.order_by(Category.name).all()
if request.method == 'POST':
for category in categories:
category.name = request.form['name.%d' % category.id]
category.slug = request.form['slug.%d' % category.id]
db_session.commit()
flash(u'Categories updated')
return redirect(url_for('manage_categories'))
return render_template('snippets/manage_categories.html',
categories=categories)
@snippets.route('/new-category/', methods=['POST'])
@requires_admin
def new_category():
category = Category(name=request.form['name'])
db_session.add(category)
db_session.commit()
flash(u'Category %s created.' % category.name)
return redirect(url_for('manage_categories'))
@snippets.route('/delete-category/<int:id>/', methods=['GET', 'POST'])
@requires_admin
def delete_category(id):
category = Category.query.get(id)
if category is None:
abort(404)
if request.method == 'POST':
if 'cancel' in request.form:
flash(u'Deletion was aborted')
return redirect(url_for('manage_categories'))
move_to_id = request.form.get('move_to', type=int)
if move_to_id:
move_to = Category.query.get(move_to_id)
if move_to is None:
flash(u'Category was removed in the meantime')
else:
for snippet in category.snippets.all():
snippet.category = move_to
db_session.delete(category)
flash(u'Category %s deleted and entries moved to %s.' %
(category.name, move_to.name))
else:
category.snippets.delete()
db_session.delete(category)
flash(u'Category %s deleted' % category.name)
db_session.commit()
return redirect(url_for('manage_categories'))
return render_template('snippets/delete_category.html',
category=category,
other_categories=Category.query
.filter(Category.id != category.id).all())
@snippets.route('/recent.atom') @snippets.route('/recent.atom')
def recent_feed(): def recent_feed():
feed = AtomFeed(u'Recent Flask Snippets', feed = AtomFeed(u'Recent Flask Snippets',

View file

@ -6,6 +6,7 @@ DEBUG = False
SECRET_KEY = 'testkey' SECRET_KEY = 'testkey'
DATABASE_URI = 'sqlite:///' + os.path.join(_basedir, 'flask-website.db') DATABASE_URI = 'sqlite:///' + os.path.join(_basedir, 'flask-website.db')
ADMINS = frozenset(['http://lucumr.pocoo.org/'])
THREADS_PER_PAGE = 15 THREADS_PER_PAGE = 15
MAILINGLIST_PATH = os.path.join(_basedir, '_mailinglist') MAILINGLIST_PATH = os.path.join(_basedir, '_mailinglist')