From 0ab7a9cb67bcb6cc916f4c7ba75100b55dbe675c Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 3 May 2010 11:20:52 +0200 Subject: [PATCH] Added snippet database to the website. --- flask_website/__init__.py | 10 +- flask_website/_openid_auth.py | 83 ----------- flask_website/database.py | 76 +++++++--- flask_website/flaskystyle.py | 86 +++++++++++ flask_website/openid_auth.py | 134 ++++++++++++++++++ flask_website/static/login.png | Bin 0 -> 22834 bytes flask_website/static/new-snippet.png | Bin 0 -> 23884 bytes flask_website/static/openid.png | Bin 0 -> 954 bytes flask_website/static/style.css | 101 ++++++++++++- .../templates/general/first_login.html | 27 ++++ flask_website/templates/general/login.html | 22 +++ flask_website/templates/layout.html | 11 +- .../templates/mailinglist/layout.html | 3 +- .../templates/snippets/category.html | 19 +++ flask_website/templates/snippets/edit.html | 44 ++++++ flask_website/templates/snippets/index.html | 31 +++- flask_website/templates/snippets/layout.html | 3 +- flask_website/templates/snippets/new.html | 43 ++++++ flask_website/templates/snippets/show.html | 38 +++++ flask_website/utils.py | 56 ++++++++ flask_website/views/general.py | 44 +++++- flask_website/views/snippets.py | 112 ++++++++++++++- 22 files changed, 825 insertions(+), 118 deletions(-) delete mode 100644 flask_website/_openid_auth.py create mode 100644 flask_website/flaskystyle.py create mode 100644 flask_website/openid_auth.py create mode 100644 flask_website/static/login.png create mode 100644 flask_website/static/new-snippet.png create mode 100644 flask_website/static/openid.png create mode 100644 flask_website/templates/general/first_login.html create mode 100644 flask_website/templates/general/login.html create mode 100644 flask_website/templates/snippets/category.html create mode 100644 flask_website/templates/snippets/edit.html create mode 100644 flask_website/templates/snippets/new.html create mode 100644 flask_website/templates/snippets/show.html create mode 100644 flask_website/utils.py 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 0000000000000000000000000000000000000000..8431b0c6972371554334fe97c9db85645826995a GIT binary patch literal 22834 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 literal 0 HcmV?d00001 diff --git a/flask_website/static/new-snippet.png b/flask_website/static/new-snippet.png new file mode 100644 index 0000000000000000000000000000000000000000..17127cdac6c5bb80b18a9ac9cc681021d2dada39 GIT binary patch literal 23884 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 literal 0 HcmV?d00001 diff --git a/flask_website/static/openid.png b/flask_website/static/openid.png new file mode 100644 index 0000000000000000000000000000000000000000..b57e66d21e5538a08824d51069ee95d2ed12d266 GIT binary patch literal 954 zcmV;r14aCaP)4Tx0C)kdlTB;XP!xvWbSSM*hfzd|t1P6r7&Xqc;OyGYhl~wlm=CIMG?QeK z(McvInNh1?e}XG1h#^-jkej z6Cm|j_x;`!0N0Cx`k7Vv{Dq71%qOHUjeW>tRyRU_MblKWOo*Q!AhBETkF#78Oq1kv zKo10@EEx{jf|nV$1veRAkNk){4r9a7P2$_c*`Qf353RXP9(3;pJ!#|s%MvpSS=3010qNS#tmY3ljhU3ljkVnw%H_ z00BBlL_t(26_rxGYJ*T1)gVnf2och3A%Sd);O6RF67^ac7rtrT493p9BE7in=( zu@W4_xCj#BPkT~>q`5akzv;W@JLlZ*9xn5Jq3Vxk>SVyss`-4W-k)d_hAz0oh6Ppf`O_GmN`MR7bHS5<`=4uYm>R;yJU$8FnAr_-K|W;h)B zz8?gEEX#|;4%lZl zn}uPRWm#R<95%sBQ52F5gk{;=- + h1 { background-image: url(/static/login.png); } + +{% 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

+
    + {% for category in categories %} +
  • {{ category.name }} ({{ category.count }}) + {% endfor %} +
+ {% 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

+
    + {% for comment in snippet.comments %} +
  • +

    + {{ comment.title or "Comment" }} + by {{ comment.author.name }} + on {{ comment.pub_date.strftime('%Y-%m-%d @ %H:%M') }} +

    {{ comment.rendered_text }}
  • + {% endfor %} +
+ {% 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)