diff --git a/flask_website/__init__.py b/flask_website/__init__.py index 5ad87fc9..c9e2e6b2 100644 --- a/flask_website/__init__.py +++ b/flask_website/__init__.py @@ -1,17 +1,25 @@ -from flask import Flask, render_template +from flask import Flask, session, g, render_template import websiteconfig as config app = Flask(__name__) app.debug = config.DEBUG +app.secret_key = config.SECRET_KEY @app.errorhandler(404) def not_found(error): return render_template('404.html'), 404 +@app.before_request +def load_currrent_user(): + g.user = User.query.filter_by(openid=session['openid']).first() \ + if 'openid' in session else None + from flask_website.views.general import general from flask_website.views.mailinglist import mailinglist from flask_website.views.snippets import snippets app.register_module(general) app.register_module(mailinglist) app.register_module(snippets) + +from flask_website.database import User diff --git a/flask_website/_openid_auth.py b/flask_website/_openid_auth.py deleted file mode 100644 index d15c29b7..00000000 --- a/flask_website/_openid_auth.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import with_statement - -from time import time -from hashlib import sha1 -from contextlib import closing - -from openid.association import Association -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 sqlalchemy.orm import scoped_session -from sqlalchemy.exceptions import SQLError - -from flask import request, redirect, abort, url_for, flash -from flask_website.database import User, db_session - - -class WebsiteOpenIDStore(OpenIDStore): - """Implements the open store for the website using the database.""" - - def storeAssociation(self, server_url, association): - assoc = OpenIDAssociation( - server_url=server_url, - handle=association.handle, - secret=association.secret.encode('base64'), - issued=association.issued, - lifetime=association.lifetime, - assoc_type=association.assoc_type - ) - db_session.add(assoc) - - def getAssociation(self, server_url, handle=None): - q = OpenIDAssociation.query.filter_by(server_url=server_url) - if handle is not None: - q = q.filter_by(handle=handle) - result_assoc = None - for item in q.all(): - assoc = Association(item.handle, item.secret.decode('base64'), - item.issued, item.lifetime, item.assoc_type) - if assoc.getExpiresIn() <= 0: - self.removeAssociation(server_url, assoc.handle) - else: - result_assoc = assoc - return result_assoc - - def removeAssociation(self, server_url, handle): - return OpenIDAssociation.filter( - (OpenIDAssociation.server_url == server_url) & - (OpenIDAssociation.handle == handle) - ).delete() - - def useNonce(self, server_url, timestamp, salt): - if abs(timestamp - time()) > nonce.SKEW: - return False - rv = OpenIDUserNonces.query.filter( - (OpenIDUserNonces.server_url == server_url) & - (OpenIDUserNonces.timestamp == timestamp) & - (OpenIDUserNonces.salt == salt) - ).first() - if rv is not None: - return False - rv = OpenIDUserNonces(server_url=server_url, timestamp=timestamp, - salt=salt) - session.add(rv) - return True - - def cleanupNonces(self): - return OpenIDUserNonces.filter( - OpenIDUserNonces.timestamp <= int(time() - nonce.SKEW) - ).delete() - - def cleanupAssociations(self): - return OpenIDAssociation.filter( - OpenIDAssociation.lifetime < int(time()) - ).delete() - - def getAuthKey(self): - return sha1(config.SECRET_KEY).hexdigest()[:self.AUTH_KEY_LEN] - - def isDump(self): - return False diff --git a/flask_website/database.py b/flask_website/database.py index 3b962d5f..9fbcae01 100644 --- a/flask_website/database.py +++ b/flask_website/database.py @@ -1,9 +1,12 @@ from datetime import datetime from sqlalchemy import create_engine, MetaData, Table, Column, Integer, \ String, DateTime, ForeignKey -from sqlalchemy.orm import scoped_session, sessionmaker, backref +from sqlalchemy.orm import scoped_session, sessionmaker, backref, relation from sqlalchemy.ext.declarative import declarative_base +from werkzeug import cached_property + +from flask import url_for from flask_website import config engine = create_engine(config.DATABASE_URI) @@ -15,20 +18,25 @@ def init_db(): Model.metadata.create_all(bind=engine) -class Model(declarative_base()): - query = db_session.query_property() +Model = declarative_base(name='Model') +Model.query = db_session.query_property() class User(Model): __tablename__ = 'users' id = Column('user_id', Integer, primary_key=True) openid = Column('openid', String(200)) - username = Column(String(40), unique=True) - password = Column(String(80)) + name = Column(String(200), unique=True) - def __init__(self, username, password): - self.username = username - self.password = password + def __init__(self, name, openid): + self.name = name + self.openid = openid + + def __eq__(self, other): + return type(self) is type(other) and self.id == other.id + + def __ne__(self, other): + return not self.__eq__(other) class Category(Model): @@ -37,40 +45,73 @@ class Category(Model): name = Column(String(50)) slug = Column(String(50)) + def __init__(self, name): + self.name = name + self.slug = '-'.join(name.split()).lower() + + @cached_property + def count(self): + return self.snippets.count() + + @property + def url(self): + return url_for('snippets.category', slug=self.slug) + class Snippet(Model): __tablename__ = 'snippets' id = Column('snippet_id', Integer, primary_key=True) - author = ForeignKey(User, backref=backref('snippets', lazy='dynamic')) - category = ForeignKey(Category, backref=backref('snippets', lazy='dynamic')) + author_id = Column(Integer, ForeignKey('users.user_id')) + author = relation(User, backref=backref('snippets', lazy='dynamic')) + category_id = Column(Integer, ForeignKey('categories.category_id')) + category = relation(Category, backref=backref('snippets', lazy='dynamic')) title = Column(String(200)) body = Column(String) - pub_date = DateTime() + pub_date = Column(DateTime) - def __init__(self, author, title, body): + def __init__(self, author, title, body, category): self.author = author self.title = title self.body = body + self.category = category self.pub_date = datetime.utcnow() + @property + def url(self): + return url_for('snippets.show', id=self.id) + + @property + def rendered_body(self): + from flask_website.utils import format_creole + return format_creole(self.body) + class Comment(Model): __tablename__ = 'comments' id = Column('comment_id', Integer, primary_key=True) - snippet = ForeignKey(Snippet, backref='lazy') - author = ForeignKey(User, backref=backref('comments', lazy='dynamic')) + snippet_id = Column(Integer, ForeignKey('snippets.snippet_id')) + snippet = relation(Snippet, backref=backref('comments', lazy=True)) + author_id = Column(Integer, ForeignKey('users.user_id')) + author = relation(User, backref=backref('comments', lazy='dynamic')) title = Column(String(200)) text = Column(String) - pub_date = DateTime() + pub_date = Column(DateTime) - def __init__(self, author, title, text): + def __init__(self, snippet, author, title, text): + self.snippet = snippet self.author = author self.title = title self.text = text self.pub_date = datetime.utcnow() + @property + def rendered_text(self): + from flask_website.utils import format_creole + return format_creole(self.text) + class OpenIDAssociation(Model): + __tablename__ = 'openid_associations' id = Column('association_id', Integer, primary_key=True) server_url = Column(String(1024)) handle = Column(String(255)) @@ -80,7 +121,8 @@ class OpenIDAssociation(Model): assoc_type = Column(String(64)) -class OpenIDUserNonces(Model): +class OpenIDUserNonce(Model): + __tablename__ = 'openid_user_nonces' id = Column('user_nonce_id', Integer, primary_key=True) server_url = Column(String(1024)) timestamp = Column(Integer) diff --git a/flask_website/flaskystyle.py b/flask_website/flaskystyle.py new file mode 100644 index 00000000..33f47449 --- /dev/null +++ b/flask_website/flaskystyle.py @@ -0,0 +1,86 @@ +# flasky extensions. flasky pygments style based on tango style +from pygments.style import Style +from pygments.token import Keyword, Name, Comment, String, Error, \ + Number, Operator, Generic, Whitespace, Punctuation, Other, Literal + + +class FlaskyStyle(Style): + background_color = "#f8f8f8" + default_style = "" + + styles = { + # No corresponding class for the following: + #Text: "", # class: '' + Whitespace: "underline #f8f8f8", # class: 'w' + Error: "#a40000 border:#ef2929", # class: 'err' + Other: "#000000", # class 'x' + + Comment: "italic #8f5902", # class: 'c' + Comment.Preproc: "noitalic", # class: 'cp' + + Keyword: "bold #004461", # class: 'k' + Keyword.Constant: "bold #004461", # class: 'kc' + Keyword.Declaration: "bold #004461", # class: 'kd' + Keyword.Namespace: "bold #004461", # class: 'kn' + Keyword.Pseudo: "bold #004461", # class: 'kp' + Keyword.Reserved: "bold #004461", # class: 'kr' + Keyword.Type: "bold #004461", # class: 'kt' + + Operator: "#582800", # class: 'o' + Operator.Word: "bold #004461", # class: 'ow' - like keywords + + Punctuation: "bold #000000", # class: 'p' + + # because special names such as Name.Class, Name.Function, etc. + # are not recognized as such later in the parsing, we choose them + # to look the same as ordinary variables. + Name: "#000000", # class: 'n' + Name.Attribute: "#c4a000", # class: 'na' - to be revised + Name.Builtin: "#004461", # class: 'nb' + Name.Builtin.Pseudo: "#3465a4", # class: 'bp' + Name.Class: "#000000", # class: 'nc' - to be revised + Name.Constant: "#000000", # class: 'no' - to be revised + Name.Decorator: "#888", # class: 'nd' - to be revised + Name.Entity: "#ce5c00", # class: 'ni' + Name.Exception: "bold #cc0000", # class: 'ne' + Name.Function: "#000000", # class: 'nf' + Name.Property: "#000000", # class: 'py' + Name.Label: "#f57900", # class: 'nl' + Name.Namespace: "#000000", # class: 'nn' - to be revised + Name.Other: "#000000", # class: 'nx' + Name.Tag: "bold #004461", # class: 'nt' - like a keyword + Name.Variable: "#000000", # class: 'nv' - to be revised + Name.Variable.Class: "#000000", # class: 'vc' - to be revised + Name.Variable.Global: "#000000", # class: 'vg' - to be revised + Name.Variable.Instance: "#000000", # class: 'vi' - to be revised + + Number: "#990000", # class: 'm' + + Literal: "#000000", # class: 'l' + Literal.Date: "#000000", # class: 'ld' + + String: "#4e9a06", # class: 's' + String.Backtick: "#4e9a06", # class: 'sb' + String.Char: "#4e9a06", # class: 'sc' + String.Doc: "italic #8f5902", # class: 'sd' - like a comment + String.Double: "#4e9a06", # class: 's2' + String.Escape: "#4e9a06", # class: 'se' + String.Heredoc: "#4e9a06", # class: 'sh' + String.Interpol: "#4e9a06", # class: 'si' + String.Other: "#4e9a06", # class: 'sx' + String.Regex: "#4e9a06", # class: 'sr' + String.Single: "#4e9a06", # class: 's1' + String.Symbol: "#4e9a06", # class: 'ss' + + Generic: "#000000", # class: 'g' + Generic.Deleted: "#a40000", # class: 'gd' + Generic.Emph: "italic #000000", # class: 'ge' + Generic.Error: "#ef2929", # class: 'gr' + Generic.Heading: "bold #000080", # class: 'gh' + Generic.Inserted: "#00A000", # class: 'gi' + Generic.Output: "#888", # class: 'go' + Generic.Prompt: "#745334", # class: 'gp' + Generic.Strong: "bold #000000", # class: 'gs' + Generic.Subheading: "bold #800080", # class: 'gu' + Generic.Traceback: "bold #a40000", # class: 'gt' + } diff --git a/flask_website/openid_auth.py b/flask_website/openid_auth.py new file mode 100644 index 00000000..9895a9a9 --- /dev/null +++ b/flask_website/openid_auth.py @@ -0,0 +1,134 @@ +from __future__ import with_statement + +from time import time +from hashlib import sha1 +from contextlib import closing + +from openid.association import Association +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 sqlalchemy.orm import scoped_session +from sqlalchemy.exceptions import SQLError + +from flask import request, redirect, abort, url_for, flash, session +from flask_website.database import User, db_session, OpenIDAssociation, \ + OpenIDUserNonce + + +class WebsiteOpenIDStore(OpenIDStore): + """Implements the open store for the website using the database.""" + + def storeAssociation(self, server_url, association): + assoc = OpenIDAssociation( + server_url=server_url, + handle=association.handle, + secret=association.secret.encode('base64'), + issued=association.issued, + lifetime=association.lifetime, + assoc_type=association.assoc_type + ) + db_session.add(assoc) + + def getAssociation(self, server_url, handle=None): + q = OpenIDAssociation.query.filter_by(server_url=server_url) + if handle is not None: + q = q.filter_by(handle=handle) + result_assoc = None + for item in q.all(): + assoc = Association(item.handle, item.secret.decode('base64'), + item.issued, item.lifetime, item.assoc_type) + if assoc.getExpiresIn() <= 0: + self.removeAssociation(server_url, assoc.handle) + else: + result_assoc = assoc + return result_assoc + + def removeAssociation(self, server_url, handle): + return OpenIDAssociation.filter( + (OpenIDAssociation.server_url == server_url) & + (OpenIDAssociation.handle == handle) + ).delete() + + def useNonce(self, server_url, timestamp, salt): + if abs(timestamp - time()) > nonce.SKEW: + return False + rv = OpenIDUserNonce.query.filter( + (OpenIDUserNonce.server_url == server_url) & + (OpenIDUserNonce.timestamp == timestamp) & + (OpenIDUserNonce.salt == salt) + ).first() + if rv is not None: + return False + rv = OpenIDUserNonce(server_url=server_url, timestamp=timestamp, + salt=salt) + db_session.add(rv) + return True + + def cleanupNonces(self): + return OpenIDUserNonce.filter( + OpenIDUserNonce.timestamp <= int(time() - nonce.SKEW) + ).delete() + + def cleanupAssociations(self): + return OpenIDAssociation.filter( + OpenIDAssociation.lifetime < int(time()) + ).delete() + + def getAuthKey(self): + return sha1(config.SECRET_KEY).hexdigest()[:self.AUTH_KEY_LEN] + + def isDump(self): + return False + + +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: + consumer = Consumer(session, WebsiteOpenIDStore()) + auth_request = consumer.begin(identity_url) + except discover.DiscoveryFailure: + flash(u'Error: The OpenID was invalid') + return redirect(url_for('general.login')) + 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() diff --git a/flask_website/static/login.png b/flask_website/static/login.png new file mode 100644 index 00000000..8431b0c6 Binary files /dev/null and b/flask_website/static/login.png differ diff --git a/flask_website/static/new-snippet.png b/flask_website/static/new-snippet.png new file mode 100644 index 00000000..17127cda Binary files /dev/null and b/flask_website/static/new-snippet.png differ diff --git a/flask_website/static/openid.png b/flask_website/static/openid.png new file mode 100644 index 00000000..b57e66d2 Binary files /dev/null and b/flask_website/static/openid.png differ diff --git a/flask_website/static/style.css b/flask_website/static/style.css index 63ed7fff..d999e7a7 100644 --- a/flask_website/static/style.css +++ b/flask_website/static/style.css @@ -2,12 +2,15 @@ body { font-family: 'Georgia', serif; font-size: 17px; color: #000; } a { color: #004B6B; } a:hover { color: #6D4100; } .box { width: 540px; margin: 40px auto; } -h1, h2, h3 { font-family: 'Garamond', 'Georgia', serif; font-weight: normal; } +h2, h3 { font-family: 'Garamond', 'Georgia', serif; font-weight: normal; } +h1 { margin: 0 0 30px 0; background: url(/static/flask.png) no-repeat center; + height: 165px; } +h1 span { display: none; } h2 { font-size: 28px; margin: 15px 0 5px 0; } h3 { font-size: 22px; margin: 15px 0 5px 0; } -code, +textarea, code, pre { font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', - monospace; font-size: 15px; background: #eee; } + monospace!important; font-size: 15px; background: #eee; } pre { padding: 7px 30px; margin: 15px -30px; line-height: 1.3; } .ig { color: #888; } p { line-height: 1.4; } @@ -19,6 +22,27 @@ blockquote { margin: 0; font-style: italic; color: #444; } .nav a { font-style: italic; } .backnav { float: right; color: #444; font-style: italic; margin: 5px 0 0 0; font-size: 0.9em; } +.message { background: #DEEBF3; color: #004B6B; padding: 5px 30px; + margin: 10px -30px; } + +/* forms */ +input, textarea { border: 1px solid black; padding: 2px; background: white; + font-family: 'Georgia', serif; font-size: 17px; color: #004B6B; } +textarea { width: 99%; } +input[type="submit"] { background: #DEEBF3; border-color: #004B6B; } +input.openid { background: url(openid.png) no-repeat 4px center; + padding-left: 26px; } + +/* snippets */ +.snippet-author { margin: 0 0 20px 0; font-size: 0.9em; } +#comment-box { background: #fafafa; margin: 45px -30px 15px -30px; padding: 10px 30px; } +.comments { margin-top: 0; } +.comments .title { border-bottom: 1px solid black; margin: 0; font-style: italic; } +.comments .body { margin: 0 0 0 30px; } +.comments .body pre { padding: 7px 30px 7px 60px; margin: 10px -30px 10px -60px; } +.comments .body p { margin: 10px 0; } +.comments li:before { display: none; } +#preview { margin: 20px -30px; padding: 10px 30px; background: #fafafa; } /* mailinglist */ .pagination { text-align: center; font-size: 15px; margin: 20px 0 0 0; } @@ -45,3 +69,74 @@ blockquote { margin: 0; font-style: italic; color: #444; } line-height: 1.15; } .mail .quote { color: #004B6B; } .mail .sig { color: #888; } + +/* pygments style, same as flaskystyle */ +.hll { background-color: #ffffcc } +.c { color: #8f5902; font-style: italic } /* Comment */ +.err { color: #a40000; border: 1px solid #ef2929 } /* Error */ +.g { color: #000000 } /* Generic */ +.k { color: #004461; font-weight: bold } /* Keyword */ +.l { color: #000000 } /* Literal */ +.n { color: #000000 } /* Name */ +.o { color: #582800 } /* Operator */ +.x { color: #000000 } /* Other */ +.p { color: #000000; font-weight: bold } /* Punctuation */ +.cm { color: #8f5902; font-style: italic } /* Comment.Multiline */ +.cp { color: #8f5902 } /* Comment.Preproc */ +.c1 { color: #8f5902; font-style: italic } /* Comment.Single */ +.cs { color: #8f5902; font-style: italic } /* Comment.Special */ +.gd { color: #a40000 } /* Generic.Deleted */ +.ge { color: #000000; font-style: italic } /* Generic.Emph */ +.gr { color: #ef2929 } /* Generic.Error */ +.gh { color: #000080; font-weight: bold } /* Generic.Heading */ +.gi { color: #00A000 } /* Generic.Inserted */ +.go { color: #808080 } /* Generic.Output */ +.gp { color: #745334 } /* Generic.Prompt */ +.gs { color: #000000; font-weight: bold } /* Generic.Strong */ +.gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +.gt { color: #a40000; font-weight: bold } /* Generic.Traceback */ +.kc { color: #004461; font-weight: bold } /* Keyword.Constant */ +.kd { color: #004461; font-weight: bold } /* Keyword.Declaration */ +.kn { color: #004461; font-weight: bold } /* Keyword.Namespace */ +.kp { color: #004461; font-weight: bold } /* Keyword.Pseudo */ +.kr { color: #004461; font-weight: bold } /* Keyword.Reserved */ +.kt { color: #004461; font-weight: bold } /* Keyword.Type */ +.ld { color: #000000 } /* Literal.Date */ +.m { color: #990000 } /* Literal.Number */ +.s { color: #4e9a06 } /* Literal.String */ +.na { color: #c4a000 } /* Name.Attribute */ +.nb { color: #004461 } /* Name.Builtin */ +.nc { color: #000000 } /* Name.Class */ +.no { color: #000000 } /* Name.Constant */ +.nd { color: #808080 } /* Name.Decorator */ +.ni { color: #ce5c00 } /* Name.Entity */ +.ne { color: #cc0000; font-weight: bold } /* Name.Exception */ +.nf { color: #000000 } /* Name.Function */ +.nl { color: #f57900 } /* Name.Label */ +.nn { color: #000000 } /* Name.Namespace */ +.nx { color: #000000 } /* Name.Other */ +.py { color: #000000 } /* Name.Property */ +.nt { color: #004461; font-weight: bold } /* Name.Tag */ +.nv { color: #000000 } /* Name.Variable */ +.ow { color: #004461; font-weight: bold } /* Operator.Word */ +.w { color: #f8f8f8; text-decoration: underline } /* Text.Whitespace */ +.mf { color: #990000 } /* Literal.Number.Float */ +.mh { color: #990000 } /* Literal.Number.Hex */ +.mi { color: #990000 } /* Literal.Number.Integer */ +.mo { color: #990000 } /* Literal.Number.Oct */ +.sb { color: #4e9a06 } /* Literal.String.Backtick */ +.sc { color: #4e9a06 } /* Literal.String.Char */ +.sd { color: #8f5902; font-style: italic } /* Literal.String.Doc */ +.s2 { color: #4e9a06 } /* Literal.String.Double */ +.se { color: #4e9a06 } /* Literal.String.Escape */ +.sh { color: #4e9a06 } /* Literal.String.Heredoc */ +.si { color: #4e9a06 } /* Literal.String.Interpol */ +.sx { color: #4e9a06 } /* Literal.String.Other */ +.sr { color: #4e9a06 } /* Literal.String.Regex */ +.s1 { color: #4e9a06 } /* Literal.String.Single */ +.ss { color: #4e9a06 } /* Literal.String.Symbol */ +.bp { color: #3465a4 } /* Name.Builtin.Pseudo */ +.vc { color: #000000 } /* Name.Variable.Class */ +.vg { color: #000000 } /* Name.Variable.Global */ +.vi { color: #000000 } /* Name.Variable.Instance */ +.il { color: #990000 } /* Literal.Number.Integer.Long */ diff --git a/flask_website/templates/general/first_login.html b/flask_website/templates/general/first_login.html new file mode 100644 index 00000000..ff42cbfa --- /dev/null +++ b/flask_website/templates/general/first_login.html @@ -0,0 +1,27 @@ +{% extends "layout.html" %} +{% block head %} + {{ super() }} + +{% endblock %} +{% block title %}First Login{% endblock %} +{% block body %} +

This is your First Login

+
+

+ This is your first login on this website. You are about to sign + in with the following OpenID: {{ openid }} +

+ Before you can use this site we need a name for you. This is what + will be displayed next to all of your public contributions to this + site. Choose wisely because you cannot change this value later: +

+
Name: +
+
+

+ + +

+{% endblock %} diff --git a/flask_website/templates/general/login.html b/flask_website/templates/general/login.html new file mode 100644 index 00000000..1234327e --- /dev/null +++ b/flask_website/templates/general/login.html @@ -0,0 +1,22 @@ +{% extends "layout.html" %} +{% block head %} + {{ super() }} + +{% endblock %} +{% block title %}Login{% endblock %} +{% block body %} +
+

+ For some of the features on this site (such as creating snippets + or adding comments) you have to be signed in. You don't need to + create an account on this website, just sign in with an existing + OpenID account. +

+ OpenID URL: + + + +

+{% endblock %} diff --git a/flask_website/templates/layout.html b/flask_website/templates/layout.html index 51e1756a..9225d4ed 100644 --- a/flask_website/templates/layout.html +++ b/flask_website/templates/layout.html @@ -15,6 +15,15 @@ documentation // mailinglist // snippets + {% for message in get_flashed_messages() %} +

{{ message }} + {% endfor %} {% block body %}{% endblock %} -

{{ category.name }}

+ {% if category.count > 3 %} +

+ Here you can find all {{ category.count }} snippets of this + category. You can also + {% else %} + This category is pretty empty so far. Why not + {% endif %} + add a new one. +

+{% endblock %} diff --git a/flask_website/templates/snippets/edit.html b/flask_website/templates/snippets/edit.html new file mode 100644 index 00000000..6050edeb --- /dev/null +++ b/flask_website/templates/snippets/edit.html @@ -0,0 +1,44 @@ +{% extends "snippets/layout.html" %} +{% block head %} + {{ super() }} + +{% endblock %} +{% block title %}Edit Snippet “{{ snippet.title }}”{% endblock %} +{% block body %} + +
+
+
Title: +
+
Category: +
+ +
+

+

+ + + +

+ {% if preview %} +
+

Preview

+ {{ preview }} +
+ {% endif %} +{% endblock %} diff --git a/flask_website/templates/snippets/index.html b/flask_website/templates/snippets/index.html index 102a8f4c..0335462d 100644 --- a/flask_website/templates/snippets/index.html +++ b/flask_website/templates/snippets/index.html @@ -4,14 +4,33 @@

Welcome to the Flask snippet archive. This is the place where anyone can drop helpful pieces of code for others to use. + {% if g.user %} +

+ You're signed in as “{{ g.user.name + }}”. You can sign out here after you're done if you want. + {% else %}

In order to add snippets to this page or to add comments, all you need is an OpenID account. -

-

- Search snippets: - - -

+ You can sign in here. + {% endif %} +

+ Want to share something? Then add a + new snippet.

Snippets by Category

+ + {% if recent %} +

Recently Added

+ + {% endif %} {% endblock %} diff --git a/flask_website/templates/snippets/layout.html b/flask_website/templates/snippets/layout.html index 56449879..cb525769 100644 --- a/flask_website/templates/snippets/layout.html +++ b/flask_website/templates/snippets/layout.html @@ -2,8 +2,7 @@ {% block head %} {{ super() }} {% endblock %} {% block body_title %} diff --git a/flask_website/templates/snippets/new.html b/flask_website/templates/snippets/new.html new file mode 100644 index 00000000..4f929d36 --- /dev/null +++ b/flask_website/templates/snippets/new.html @@ -0,0 +1,43 @@ +{% extends "snippets/layout.html" %} +{% block head %} + {{ super() }} + +{% endblock %} +{% block title %}New Snippet{% endblock %} +{% block body %} +

+ Got something to share? Here you can create a new snippet and + publish it here. By adding the snippet here, you hereby grant + the user of the snippet all rights. +

+ The syntax used for snippets is Creole. + To highlight Python code or Jinja templates, prefix your code blocks + with #!python, #!html+jinja or any other + Pygments lexer name. +

+
+
Title: +
+
Category: +
+ +
+

+

+ + +

+ {% if preview %} +
+

Preview

+ {{ preview }} +
+ {% endif %} +{% endblock %} diff --git a/flask_website/templates/snippets/show.html b/flask_website/templates/snippets/show.html new file mode 100644 index 00000000..fec67d8b --- /dev/null +++ b/flask_website/templates/snippets/show.html @@ -0,0 +1,38 @@ +{% extends "snippets/layout.html" %} +{% block title %}{{ snippet.title }}{% endblock %} +{% block body %} +

{{ snippet.title }}

+

By {{ snippet.author.name }} + filed in {{ snippet.category.name }} + {% if snippet.author == g.user %} + (edit) + {% endif %} + {{ snippet.rendered_body }} + {% if snippet.comments or g.user %} +

+ {% if snippet.comments %} +

Comments

+ + {% endif %} + {% if g.user %} +
+

Add Comment

+
+

Title: +

+

+

+
+ {% endif %} +
+ {% endif %} +{% endblock %} diff --git a/flask_website/utils.py b/flask_website/utils.py new file mode 100644 index 00000000..1a63b661 --- /dev/null +++ b/flask_website/utils.py @@ -0,0 +1,56 @@ +import creoleparser +from genshi import builder +from functools import wraps +from creoleparser.elements import PreBlock +from pygments import highlight +from pygments.formatters import HtmlFormatter +from pygments.lexers import get_lexer_by_name +from pygments.util import ClassNotFound +from flask import g, url_for, flash, request, redirect, Markup +from flask_website.flaskystyle import FlaskyStyle # same as docs + +from flask_website.database import User + +pygments_formatter = HtmlFormatter(style=FlaskyStyle) + + +class CodeBlock(PreBlock): + + def __init__(self): + super(CodeBlock, self).__init__('pre', ['{{{', '}}}']) + + def _build(self, mo, element_store, environ): + lines = self.regexp2.sub(r'\1', mo.group(1)).splitlines() + if lines and lines[0].startswith('#!'): + try: + lexer = get_lexer_by_name(lines.pop(0)[2:].strip()) + except ClassNotFound: + pass + else: + return Markup(highlight(u'\n'.join(lines), lexer, + pygments_formatter)) + return builder.tag.pre(u'\n'.join(lines)) + + +custom_dialect = creoleparser.create_dialect(creoleparser.creole10_base) +custom_dialect.pre = CodeBlock() + + +_parser = creoleparser.Parser( + dialect=custom_dialect, + method='html' +) + + +def format_creole(text): + return Markup(_parser.render(text, encoding=None)) + + +def requires_login(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if g.user is None: + flash(u'You need to be signed in for this page.') + return redirect(url_for('general.login', next=request.path)) + return f(*args, **kwargs) + return decorated_function diff --git a/flask_website/views/general.py b/flask_website/views/general.py index b0023d1e..a32b1fea 100644 --- a/flask_website/views/general.py +++ b/flask_website/views/general.py @@ -1,4 +1,7 @@ -from flask import Module, render_template +from flask import Module, render_template, session, redirect, url_for, \ + request, flash, g, Response +from flask_website import openid_auth +from flask_website.database import db_session, User general = Module(__name__) @@ -6,3 +9,42 @@ general = Module(__name__) @general.route('/') def index(): return render_template('general/index.html') + + +@general.route('/logout/') +def logout(): + if 'openid' in session: + flash(u'Logged out') + del session['openid'] + return redirect(request.referrer or url_for('general.index')) + + +@general.route('/login/', methods=['GET', 'POST']) +def login(): + if g.user is not None: + return redirect(url_for('general.index')) + rv = openid_auth.check_return_from_provider() + if rv is not None: + return rv + if request.method == 'POST': + openid = request.values.get('openid') + if openid: + return openid_auth.login(openid) + return render_template('general/login.html') + + +@general.route('/first-login/', methods=['GET', 'POST']) +def first_login(): + if g.user is not None or 'openid' not in session: + return redirect(url_for('login')) + if request.method == 'POST': + if 'cancel' in request.form: + del session['openid'] + flash(u'Login was aborted') + return redirect(url_for('general.login')) + db_session.add(User(request.form['name'], session['openid'])) + db_session.commit() + flash(u'Successfully created profile and logged in') + return openid_auth.redirect_back() + return render_template('general/first_login.html', + openid=session['openid']) diff --git a/flask_website/views/snippets.py b/flask_website/views/snippets.py index 7bfe9481..0eb4f192 100644 --- a/flask_website/views/snippets.py +++ b/flask_website/views/snippets.py @@ -1,8 +1,116 @@ -from flask import Module, render_template +from flask import Module, render_template, request, flash, abort, redirect, \ + g, url_for +from flask_website.utils import requires_login, format_creole +from flask_website.database import Category, Snippet, Comment, db_session snippets = Module(__name__, url_prefix='/snippets') @snippets.route('/') def index(): - return render_template('snippets/index.html') + return render_template('snippets/index.html', + categories=Category.query.order_by(Category.name).all(), + recent=Snippet.query.order_by(Snippet.pub_date.desc()).limit(5).all()) + + +@snippets.route('/new/', methods=['GET', 'POST']) +@requires_login +def new(): + category_id = None + preview = None + if 'category' in request.args: + rv = Category.query.filter_by(slug=request.args['category']).first() + if rv is not None: + category_id = rv.id + if request.method == 'POST': + if 'preview' in request.form: + preview = format_creole(request.form['body']) + else: + title = request.form['title'] + body = request.form['body'] + category_id = request.form.get('category', type=int) + if not body: + flash(u'Error: you have to enter a snippet') + else: + category = Category.query.get(category_id) + if category is not None: + snippet = Snippet(g.user, title, body, category) + db_session.add(snippet) + db_session.commit() + flash(u'Your snippet was added') + return redirect(snippet.url) + return render_template('snippets/new.html', + categories=Category.query.order_by(Category.name).all(), + active_category=category_id, preview=preview) + + +@snippets.route('//', methods=['GET', 'POST']) +def show(id): + snippet = Snippet.query.get(id) + if snippet is None: + abort(404) + if request.method == 'POST': + title = request.form['title'] + text = request.form['text'] + if not title: + flash(u'Error: the title is required') + elif not text: + flash(u'Error: the text is required') + else: + db_session.add(Comment(snippet, g.user, title, text)) + db_session.commit() + flash(u'Your comment was added') + return redirect(snippet.url) + return render_template('snippets/show.html', snippet=snippet) + + +@snippets.route('/edit//', methods=['GET', 'POST']) +@requires_login +def edit(id): + snippet = Snippet.query.get(id) + if snippet is None: + abort(404) + if snippet.author != g.user: + abort(401) + preview = None + form = dict(title=snippet.title, body=snippet.body, + category=snippet.category.id) + if request.method == 'POST': + form['title'] = request.form['title'] + form['body'] = request.form['body'] + form['category'] = request.form.get('category', type=int) + if 'preview' in request.form: + preview = format_creole(request.form['body']) + elif 'delete' in request.form: + for comment in snippet.comments: + db_session.delete(comment) + db_session.delete(snippet) + db_session.commit() + flash(u'Your snippet was deleted') + return redirect(url_for('snippets.index')) + else: + category_id = request.form.get('category', type=int) + if not form['body']: + flash(u'Error: you have to enter a snippet') + else: + category = Category.query.get(category_id) + if category is not None: + snippet.title = form['title'] + snippet.body = form['body'] + snippet.category = category + db_session.commit() + flash(u'Your snippet was modified') + return redirect(snippet.url) + return render_template('snippets/edit.html', + snippet=snippet, preview=preview, form=form, + categories=Category.query.order_by(Category.name).all()) + + +@snippets.route('/category//') +def category(slug): + category = Category.query.filter_by(slug=slug).first() + if category is None: + abort(404) + snippets = category.snippets.order_by(Snippet.title).all() + return render_template('snippets/category.html', category=category, + snippets=snippets)