├── README.md ├── application ├── __init__.py ├── admin.py ├── forum │ ├── __init__.py │ ├── forms.py │ └── views.py ├── models.py ├── static │ └── less │ │ └── style.less └── templates │ ├── forum │ ├── board.html │ ├── create_post.html │ ├── create_thread.html │ ├── edit_post.html │ ├── index.html │ ├── layout.html │ ├── macros.html │ ├── thread.html │ └── user.html │ ├── index.html │ └── layout.html ├── config.py ├── manage.py ├── requirements.txt ├── runserver.py ├── seed.py └── test_forum.py /README.md: -------------------------------------------------------------------------------- 1 | *(Note: I made this project as an experiment 7 years ago while I was learning Flask. 2 | The code is entirely "as-is," and I don't intend to offer any support for it. Even 3 | so, I hope you find it useful.)* 4 | 5 | License: MIT 6 | 7 | # flask-forum 8 | 9 | A forum app with some basic forum stuff: 10 | 11 | - Authentication and session management 12 | - Create boards, threads, and posts 13 | - Write and edit posts in Markdown 14 | 15 | Most of the forum code is contained in its own folder, and it's pretty easy to 16 | move it to other projects and use it as a blueprint or whatever else you have 17 | in mind. If you're new to Flask, this project is also a pretty good illustration 18 | of how to use a variety of common and especially useful extensions. 19 | 20 | ## Extensions used 21 | 22 | - [Flask-Admin](http://flask-admin.readthedocs.org/en/latest/) for database management 23 | - [Flask-Assets](http://elsdoerfer.name/docs/flask-assets/) for asset management 24 | - [Flask-DebugToolbar](http://flask-debugtoolbar.readthedocs.org/) for debugging and profiling. 25 | - [Flask-Markdown](http://pythonhosted.org/Flask-Markdown/) for forum posts 26 | - [Flask-Script](http://flask-script.readthedocs.org/en/latest/) for basic commands 27 | - [Flask-Security](http://pythonhosted.org/Flask-Security/) for authentication 28 | - [Flask-SQLAlchemy](http://pythonhosted.org/Flask-SQLAlchemy/) for database queries 29 | - [Flask-WTF](http://pythonhosted.org/Flask-WTF/) for forms 30 | 31 | ## Setup 32 | 33 | flask-forum uses [bcrypt](https://github.com/dstufft/bcrypt/) for password hashing. 34 | If you're using Ubuntu, you can install it with the necessary headers by running 35 | the following commands: 36 | 37 | ``` 38 | sudo apt-get install python-dev libffi-dev 39 | sudo pip install bcrypt 40 | ``` 41 | 42 | The rest of the setup is more conventional: 43 | 44 | ``` 45 | pip install -r requirements.txt 46 | python manage.py create_db 47 | python manage.py create_user -e -p 48 | python manage.py create_role -n admin 49 | python manage.py add_role -u -r admin 50 | python runserver.py 51 | ``` 52 | 53 | By default the site is hosted at `localhost:5000`. 54 | 55 | -------------------------------------------------------------------------------- /application/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, render_template 2 | from flask.ext.assets import Bundle, Environment 3 | from flask.ext.markdown import Markdown 4 | from flask.ext.security import Security, SQLAlchemyUserDatastore 5 | from flask.ext.sqlalchemy import SQLAlchemy 6 | 7 | 8 | app = Flask(__name__) 9 | app.config.from_object('config') 10 | 11 | 12 | # Assets 13 | assets = Environment(app) 14 | assets.url = '/static' 15 | assets.directory = app.config['ASSETS_DEST'] 16 | 17 | less = Bundle('less/style.less', filters='less', output='gen/style.css') 18 | assets.register('all-css', less) 19 | 20 | 21 | # Database 22 | db = SQLAlchemy(app) 23 | import models 24 | 25 | 26 | # Admin 27 | import admin 28 | 29 | 30 | # Markdown 31 | Markdown(app, safe_mode='escape') 32 | 33 | 34 | # Debug toolbar 35 | if app.config['DEBUG']: 36 | from flask.ext.debugtoolbar import DebugToolbarExtension as DTE 37 | toolbar = DTE(app) 38 | 39 | 40 | # Security 41 | datastore = SQLAlchemyUserDatastore(db, models.User, models.Role) 42 | security = Security(app, datastore) 43 | 44 | 45 | # Endpoints 46 | @app.route('/') 47 | def index(): 48 | return render_template('index.html', User=models.User) 49 | 50 | 51 | import forum.views as forum 52 | app.register_blueprint(forum.bp, url_prefix='/forum') 53 | -------------------------------------------------------------------------------- /application/admin.py: -------------------------------------------------------------------------------- 1 | from flask import abort 2 | from flask.ext.admin import (Admin, BaseView as _BaseView, 3 | AdminIndexView as _AdminIndexView, 4 | expose) 5 | from flask.ext.admin.contrib.sqlamodel import ModelView as _ModelView 6 | from flask.ext.security import current_user 7 | 8 | from application import app, db, models 9 | 10 | 11 | # Base classes 12 | # ------------ 13 | class AuthMixin(object): 14 | def is_accessible(self): 15 | """Returns ``True`` if `current_user` has access to admin views. 16 | This method checks whether `current_user` has the ``'admin'`` 17 | role. 18 | """ 19 | return current_user.has_role('admin') 20 | 21 | 22 | class AdminIndexView(_AdminIndexView): 23 | """An `:class:`~flask.ext.admin.AdminIndexView` with authentication""" 24 | @expose('/') 25 | def index(self): 26 | if current_user.has_role('admin'): 27 | return self.render(self._template) 28 | else: 29 | abort(404) 30 | 31 | 32 | class BaseView(AuthMixin, _BaseView): 33 | """A `:class:`~flask.ext.admin.BaseView` with authentication.""" 34 | pass 35 | 36 | 37 | class ModelView(AuthMixin, _ModelView): 38 | """A `:class:`~flask.ext.admin.contrib.sqlamodel.ModelView` with 39 | authentication. 40 | """ 41 | pass 42 | 43 | 44 | # Custom views 45 | # ------------ 46 | class UserView(ModelView): 47 | column_exclude_list = form_excluded_columns = ['password'] 48 | 49 | 50 | # Admin setup 51 | # ----------- 52 | admin = Admin(name='Index', index_view=AdminIndexView()) 53 | 54 | admin.add_view(ModelView(models.Board, db.session, 55 | category='Forum', 56 | name='Boards')) 57 | admin.add_view(ModelView(models.Thread, db.session, 58 | category='Forum', 59 | name='Threads')) 60 | admin.add_view(ModelView(models.Post, db.session, 61 | category='Forum', 62 | name='Posts')) 63 | admin.add_view(UserView(models.User, db.session, 64 | category='Auth', 65 | name='Users')) 66 | admin.add_view(ModelView(models.Role, db.session, 67 | category='Auth', 68 | name='Roles')) 69 | 70 | 71 | admin.init_app(app) 72 | -------------------------------------------------------------------------------- /application/forum/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akprasad/flask-forum/00c251acf795f69a4d1205831811a0da0e9dfc74/application/forum/__init__.py -------------------------------------------------------------------------------- /application/forum/forms.py: -------------------------------------------------------------------------------- 1 | from flask.ext.wtf import Form 2 | from wtforms import SubmitField, TextField, TextAreaField 3 | from wtforms.validators import Required 4 | 5 | 6 | class CreateThreadForm(Form): 7 | name = TextField(validators=[Required()]) 8 | content = TextAreaField(validators=[Required()]) 9 | submit = SubmitField() 10 | 11 | 12 | class CreatePostForm(Form): 13 | content = TextAreaField(validators=[Required()]) 14 | submit = SubmitField() 15 | 16 | 17 | class EditPostForm(CreatePostForm): 18 | pass 19 | -------------------------------------------------------------------------------- /application/forum/views.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.exc import SQLAlchemyError as sql_exc 2 | from flask import Blueprint, redirect, render_template, url_for 3 | from flask.ext.security import current_user, login_required 4 | 5 | from application import db 6 | from application.models import Board, Thread, Post, User 7 | import forms 8 | 9 | GET_POST = ['GET', 'POST'] 10 | 11 | bp = Blueprint('forum', __name__) 12 | 13 | 14 | @bp.route('/') 15 | def index(): 16 | boards = Board.query.all() 17 | return render_template('forum/index.html', boards=boards) 18 | 19 | 20 | @bp.route('//') 21 | def board(slug): 22 | try: 23 | board = Board.query.filter(Board.slug == slug).one() 24 | threads = Thread.query.filter(Thread.board_id == board.id) \ 25 | .order_by(Thread.updated.desc()).all() 26 | except sql_exc: 27 | return redirect(url_for('.index')) 28 | 29 | return render_template('forum/board.html', board=board, 30 | threads=threads) 31 | 32 | 33 | @bp.route('//') 34 | @bp.route('//-') 35 | def thread(slug, id, title=None): 36 | try: 37 | board = Board.query.filter(Board.slug == slug).one() 38 | except sql_exc: 39 | return redirect(url_for('.index')) 40 | 41 | try: 42 | thread = Thread.query.filter(Thread.id == id).one() 43 | except sql_exc: 44 | return redirect(url_for('.board', slug=slug)) 45 | 46 | return render_template('forum/thread.html', board=board, thread=thread, 47 | posts=thread.posts) 48 | 49 | 50 | @bp.route('/users/<int:id>') 51 | def user(id): 52 | try: 53 | user = User.query.filter(User.id == id).one() 54 | except sql_exc: 55 | return redirect(url_for('.index')) 56 | 57 | return render_template('forum/user.html', user=user) 58 | 59 | 60 | @bp.route('/<slug>/create/', methods=GET_POST) 61 | @login_required 62 | def create_thread(slug): 63 | try: 64 | board = Board.query.filter(Board.slug == slug).one() 65 | except sql_exc: 66 | return redirect(url_for('.index')) 67 | 68 | form = forms.CreateThreadForm() 69 | if form.validate_on_submit(): 70 | t = Thread( name=form.name.data, board=board, author=current_user) 71 | db.session.add(t) 72 | db.session.flush() 73 | 74 | p = Post(content=form.content.data, author=current_user) 75 | t.posts.append(p) 76 | db.session.commit() 77 | 78 | return redirect(url_for('.board', slug=slug)) 79 | 80 | return render_template('forum/create_thread.html', board=board, 81 | form=form) 82 | 83 | 84 | @bp.route('/<slug>/<int:id>/create', methods=GET_POST) 85 | @login_required 86 | def create_post(slug, id): 87 | try: 88 | board = Board.query.filter(Board.slug == slug).one() 89 | except sql_exc: 90 | return redirect(url_for('.index')) 91 | try: 92 | thread = Thread.query.filter(Thread.id == id).one() 93 | except sql_exc: 94 | return redirect(url_for('.board', slug=slug)) 95 | 96 | form = forms.CreatePostForm() 97 | if form.validate_on_submit(): 98 | p = Post(content=form.content.data, author=current_user) 99 | thread.posts.append(p) 100 | db.session.flush() 101 | thread.updated = p.created 102 | db.session.commit() 103 | 104 | return redirect(url_for('.thread', slug=slug, id=id)) 105 | 106 | return render_template('forum/create_post.html', board=board, 107 | thread=thread, form=form) 108 | 109 | 110 | @bp.route('/<slug>/<int:thread_id>/<int:post_id>/edit', methods=GET_POST) 111 | @login_required 112 | def edit_post(slug, thread_id, post_id): 113 | try: 114 | board = Board.query.filter(Board.slug == slug).one() 115 | except sql_exc: 116 | return redirect(url_for('.index')) 117 | try: 118 | thread = Thread.query.filter(Thread.id == thread_id).one() 119 | except sql_exc: 120 | return redirect(url_for('.board', slug=slug)) 121 | 122 | thread_redirect = redirect(url_for('.thread', slug=slug, id=thread_id)) 123 | try: 124 | post = Post.query.filter(Post.id == post_id).one() 125 | except sql_exc: 126 | return thread_redirect 127 | if post.author_id != current_user.id: 128 | return thread_redirect 129 | 130 | form = forms.EditPostForm() 131 | if form.validate_on_submit(): 132 | post.content = form.content.data 133 | db.session.commit() 134 | return thread_redirect 135 | else: 136 | form.content.data = post.content 137 | 138 | return render_template('forum/create_post.html', board=board, 139 | thread=thread, form=form) 140 | -------------------------------------------------------------------------------- /application/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from flask.ext.security import UserMixin, RoleMixin 4 | from sqlalchemy import event 5 | from sqlalchemy.ext.declarative import declared_attr 6 | from sqlalchemy.ext.orderinglist import ordering_list 7 | 8 | from application import db 9 | 10 | 11 | class Base(db.Model): 12 | 13 | """A base class that automatically creates the table name and 14 | primary key. 15 | """ 16 | 17 | __abstract__ = True 18 | id = db.Column(db.Integer, primary_key=True) 19 | 20 | @declared_attr 21 | def __tablename__(cls): 22 | return cls.__name__.lower() 23 | 24 | 25 | class TimestampMixin(object): 26 | created = db.Column(db.DateTime, default=datetime.utcnow) 27 | updated = db.Column(db.DateTime, default=datetime.utcnow) 28 | 29 | def readable_date(self, date, format='%H:%M on %-d %B'): 30 | """Format the given date using the given format.""" 31 | return date.strftime(format) 32 | 33 | 34 | # Authentication 35 | # ~~~~~~~~~~~~~~ 36 | class UserRoleAssoc(db.Model): 37 | 38 | """Associates a user with a role.""" 39 | 40 | __tablename__ = 'user_role_assoc' 41 | user_id = db.Column(db.ForeignKey('user.id'), primary_key=True) 42 | role_id = db.Column(db.ForeignKey('role.id'), primary_key=True) 43 | 44 | 45 | class User(Base, UserMixin): 46 | 47 | """ 48 | A forum user. `UserMixin` provides the following methods: 49 | 50 | `is_active(self)` 51 | Returns ``True`` if the user is active. 52 | 53 | `is_authenticated(self)` 54 | Always returns ``True``. 55 | 56 | `is_anonymous(self)` 57 | Always returns ``False``. 58 | 59 | `get_auth_token(self)` 60 | Returns the user's authentication token. 61 | 62 | `has_role(self, role)` 63 | Returns ``True`` if the user identifies with the specified role. 64 | 65 | `get_id(self)` 66 | Returns ``self.id``. 67 | 68 | `__eq__(self, other)` 69 | Returns ``True`` if the two users have the same id. 70 | 71 | `__ne__(self, other)` 72 | Returns the opposite of `__eq__`. 73 | """ 74 | 75 | email = db.Column(db.String, unique=True) 76 | password = db.Column(db.String(255)) 77 | active = db.Column(db.Boolean) 78 | created = db.Column(db.DateTime, default=datetime.utcnow) 79 | 80 | roles = db.relationship('Role', secondary='user_role_assoc', 81 | backref='users') 82 | 83 | def __repr__(self): 84 | return '<User(%s, %s)>' % (self.id, self.email) 85 | 86 | def __unicode__(self): 87 | return self.email 88 | 89 | 90 | class Role(Base, RoleMixin): 91 | 92 | """ 93 | A specific role. `RoleMixin` provides the following methods: 94 | 95 | `__eq__(self, other)` 96 | Returns ``True`` if the `name` attributes are the same. If 97 | `other` is a string, returns `self.name == other`. 98 | 99 | `__ne__(self, other)` 100 | """ 101 | 102 | name = db.Column(db.String) 103 | description = db.Column(db.String) 104 | 105 | def __repr__(self): 106 | return '<Role(%s, %s)>' % (self.id, self.name) 107 | 108 | 109 | # Forum 110 | # ~~~~~ 111 | class Board(Base): 112 | 113 | #: The human-readable name, e.g. "Python 3" 114 | name = db.Column(db.String) 115 | 116 | #: The URL-encoded name, e.g. "python-3" 117 | slug = db.Column(db.String, unique=True) 118 | 119 | #: A short description of what the board contains. 120 | description = db.Column(db.Text) 121 | 122 | #: The threads associated with this board. 123 | threads = db.relationship('Thread', cascade='all,delete', backref='board') 124 | 125 | def __unicode__(self): 126 | return self.name 127 | 128 | 129 | class Thread(Base, TimestampMixin): 130 | name = db.Column(db.String(80)) 131 | 132 | #: The original author of the thread. 133 | author_id = db.Column(db.ForeignKey('user.id'), index=True) 134 | author = db.relationship('User', backref='threads') 135 | 136 | #: The parent board. 137 | board_id = db.Column(db.ForeignKey('board.id'), index=True) 138 | 139 | #: An ordered collection of posts 140 | posts = db.relationship('Post', backref='thread', 141 | cascade='all,delete', 142 | order_by='Post.index', 143 | collection_class=ordering_list('index')) 144 | 145 | #: Length of the threads 146 | length = db.Column(db.Integer, default=0) 147 | 148 | def __unicode__(self): 149 | return self.name 150 | 151 | 152 | class Post(Base, TimestampMixin): 153 | #: Used to order the post within its :class:`Thread` 154 | index = db.Column(db.Integer, default=0, index=True) 155 | 156 | #: The post content. The site views expect Markdown by default, but 157 | #: you can store anything here. 158 | content = db.Column(db.Text) 159 | 160 | #: The original author of the post. 161 | author_id = db.Column(db.ForeignKey('user.id'), index=True) 162 | author = db.relationship('User', backref='posts') 163 | 164 | #: The parent thread. 165 | thread_id = db.Column(db.ForeignKey('thread.id'), index=True) 166 | 167 | 168 | def __repr__(self): 169 | return '<Post(%s)>' % self.id 170 | 171 | 172 | def thread_posts_append(thread, post, initiator): 173 | """Update some thread values when `Thread.posts.append` is called.""" 174 | thread.length += 1 175 | thread.updated = datetime.utcnow() 176 | 177 | event.listen(Thread.posts, 'append', thread_posts_append) 178 | -------------------------------------------------------------------------------- /application/static/less/style.less: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | font-size: 14px; 4 | max-width: 960px; 5 | margin: 1em auto; 6 | padding: 0 20px; 7 | } 8 | 9 | code { 10 | background: #ffa; 11 | padding: 0 0.2em; 12 | } 13 | 14 | input[type='text'], textarea { 15 | border: 1px #ccc solid; 16 | display: block; 17 | width: 50%; 18 | } 19 | 20 | input[type='submit'] { 21 | padding: 1em; 22 | } 23 | 24 | input[type='text'] { 25 | padding: 0.5em; 26 | } 27 | 28 | p { 29 | line-height: 1.6em; 30 | } 31 | 32 | textarea { 33 | height: 20em; 34 | } 35 | 36 | div.breadcrumbs { 37 | background: #eee; 38 | padding: 1em; 39 | } 40 | 41 | #threads { 42 | 43 | width: 100%; 44 | 45 | // Make thread titles easier to click 46 | a { 47 | display: block; 48 | padding: 0.3em; 49 | 50 | &:hover { 51 | background: #06f; 52 | color: #fff; 53 | } 54 | } 55 | 56 | // Align th with td 57 | th { 58 | text-align: left; 59 | width: 60%; 60 | 61 | & + th { 62 | width: 20%; 63 | } 64 | } 65 | 66 | tr:nth-child(even) { 67 | background: #f5f5f5; 68 | } 69 | } 70 | 71 | article.post { 72 | // Make posts visually distinct 73 | margin: 0 0 1em; 74 | padding: 0.25em 1em; 75 | 76 | &:nth-child(even) { 77 | background: #f5f5f5; 78 | border: 1px #eee solid; 79 | border-width: 1px 0; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /application/templates/forum/board.html: -------------------------------------------------------------------------------- 1 | {% extends 'forum/layout.html' %} 2 | {% import 'forum/macros.html' as m %} 3 | 4 | {# #} 5 | {% macro thread_row(t) %} 6 | <tr> 7 | <td><a href="{{ url_for('.thread', slug=board.slug, id=t.id) }}">{{ t.name }}</a></td> 8 | <td>{{ t.length }}</td> 9 | <td>{{ t.readable_date(t.updated) }}</td> 10 | </tr> 11 | {% endmacro %} 12 | 13 | {% block title %}{{ board.name }}{% endblock %} 14 | 15 | {% block body %} 16 | {{ m.breadcrumbs(board) }} 17 | <h1>{{ board.name }}</h1> 18 | 19 | <a href="{{ url_for('.create_thread', slug=board.slug) }}">New thread</a> 20 | <table id="threads"> 21 | <tr> 22 | <th>Subject</th> 23 | <th>Posts</th> 24 | <th>Last updated</th> 25 | </tr> 26 | {% for t in threads %}{{ thread_row(t) }}{% endfor %} 27 | </table> 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /application/templates/forum/create_post.html: -------------------------------------------------------------------------------- 1 | {% extends 'forum/layout.html' %} 2 | {% import 'forum/macros.html' as m %} 3 | 4 | {% block title %}New post in "{{ thread.name }}"{% endblock %} 5 | 6 | {% block body %} 7 | <h1>{{ thread.name }}</h1> 8 | 9 | <form method="POST" action=""> 10 | {{ form.csrf_token }} 11 | {{ form.content }} 12 | {{ form.submit }} 13 | </form> 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /application/templates/forum/create_thread.html: -------------------------------------------------------------------------------- 1 | {% extends 'forum/layout.html' %} 2 | {% import 'forum/macros.html' as m %} 3 | 4 | {% block title %}{% endblock %} 5 | 6 | {% block body %} 7 | <h1>{{ board.name }}</h1> 8 | 9 | <form method="POST" action=""> 10 | {{ form.csrf_token }} 11 | {{ form.name }} 12 | {{ form.content }} 13 | {{ form.submit }} 14 | </form> 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /application/templates/forum/edit_post.html: -------------------------------------------------------------------------------- 1 | {% extends 'forum/layout.html' %} 2 | {% import 'forum/macros.html' as m %} 3 | 4 | {% block title %}Edit post in "{{ thread.name }}"{% endblock %} 5 | 6 | {% block body %} 7 | <h1>{{ thread.name }}</h1> 8 | 9 | <form method="POST" action=""> 10 | {{ form.csrf_token }} 11 | {{ form.content }} 12 | {{ form.submit }} 13 | </form> 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /application/templates/forum/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'forum/layout.html' %} 2 | {% import 'forum/macros.html' as m %} 3 | 4 | {% block title %}Forum{% endblock %} 5 | 6 | {% block body %} 7 | {{ m.breadcrumbs(board) }} 8 | <h1>Forum</h1> 9 | 10 | {% for b in boards %} 11 | <h2><a href="{{ url_for('.board', slug=b.slug) }}">{{ b.name }}</a></h2> 12 | <p>{{ b.description }}</p> 13 | {% endfor %} 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /application/templates/forum/layout.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | -------------------------------------------------------------------------------- /application/templates/forum/macros.html: -------------------------------------------------------------------------------- 1 | {# Breadcrumb navigation #} 2 | {% macro breadcrumbs(board=none, thread=none) %} 3 | <div class="breadcrumbs"> 4 | <a href="{{ url_for('index') }}">Index</a> 5 | » <a href="{{ url_for('forum.index') }}">Forum</a> 6 | {% if board %} 7 | » <a href="{{ url_for('forum.board', slug=board.slug) }}">{{ board.name }}</a> 8 | {% if thread %} 9 | » <a href="{{ url_for('forum.thread', slug=board.slug, id=thread.id) }}">{{ thread.name }}</a> 10 | {% endif %} 11 | {% endif %} 12 | </div> 13 | {% endmacro %} 14 | -------------------------------------------------------------------------------- /application/templates/forum/thread.html: -------------------------------------------------------------------------------- 1 | {% extends 'forum/layout.html' %} 2 | {% import 'forum/macros.html' as m %} 3 | 4 | {% block title %}{{ thread.name }}{% endblock %} 5 | 6 | {% block body %} 7 | {{ m.breadcrumbs(board, thread) }} 8 | <h1>{{ thread.name }}</h1> 9 | 10 | <a href="{{ url_for('.create_post', slug=board.slug, id=thread.id) }}">New post</a> 11 | 12 | {% for p in posts %} 13 | <article class="post"> 14 | <a href="{{ url_for('.user', id=p.author_id) }}">{{ p.author.email }}</a> 15 | {{ p.readable_date(p.created) }} 16 | {{ p.content|markdown }} 17 | {% if p.author_id == current_user.id %}<a href="{{ url_for('.edit_post', slug=board.slug, thread_id=thread.id, post_id=p.id) }}">[edit]</a>{% endif %} 18 | </article> 19 | {% endfor %} 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /application/templates/forum/user.html: -------------------------------------------------------------------------------- 1 | {% extends 'forum/layout.html' %} 2 | {% import 'forum/macros.html' as m %} 3 | 4 | {% block title %}{{ user.email }}{% endblock %} 5 | 6 | {% block body %} 7 | {{ m.breadcrumbs() }} 8 | <h1>{{ user.email }}</h1> 9 | 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /application/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {# shorthand #} 4 | {% set u = current_user %} 5 | {% set auth = u.is_authenticated() %} 6 | 7 | {% block title %}flask-forum{% endblock %} 8 | 9 | {% block body %} 10 | <h1>flask-forum</h1> 11 | {% if auth %}(logged in as <i>{{ u.email }}</i>){% endif %} 12 | 13 | <h2>Authentication [<a href="{{ url_for('security.login') }}">log in</a>, <a href="{{ url_for('security.logout') }}">log out</a>, <a href="{{ url_for('security.register') }}">register</a>]</h2> 14 | <p>Powered by Flask-Security. Flask-Security automatically creates several endpoints, including:</p> 15 | 16 | <ul> 17 | <li><code>security.login</code> (<code>/login</code>)</li> 18 | <li><code>security.logout</code> (<code>/logout</code>)</li> 19 | <li><code>security.register</code> (<code>/register</code>)</li> 20 | </ul> 21 | 22 | 23 | <h2><a href="{{ url_for('admin.index') }}">Admin stuff</a></h2> 24 | {% if auth %} 25 | {% if u.has_role('admin') %} 26 | <p>The account you're using (<code>{{ u.email }}</code>) has admin privileges. Why not <a href="{{ url_for('admin.index') }}">check out the admin panel</a>?</p> 27 | {% else %} 28 | <p>The account you're using (<code>{{ u.email }}</code>) does not have admin privileges. You can give yourself admin privileges by running the following commands on the terminal:</p> 29 | 30 | <pre> 31 | python manage.py create_role -n admin 32 | python manage.py add_role -u {{ u.email }} -r admin 33 | </pre> 34 | {% endif %} 35 | {% else %} 36 | <p><a href="{{ url_for('security.login') }}">Log in</a> to access the admin panel.</p> 37 | {% endif %} 38 | 39 | 40 | <h2><a href="{{ url_for('forum.index') }}">The forum</a></h2> 41 | 42 | <p>A basic forum. There are multiple <dfn>boards</dfn>, each board has multiple <dfn>threads</dfn>, and each thread has multiple <dfn>posts</dfn>. Users can create threads and posts, but only administrators can create boards. You can create a new board by using the <a href="{{ url_for('boardview.create_view') }}">admin panel</a>.</p> 43 | 44 | {% endblock %} 45 | -------------------------------------------------------------------------------- /application/templates/layout.html: -------------------------------------------------------------------------------- 1 | <html> 2 | <head> 3 | {% block head %} 4 | <title>{% block title %}{% endblock %} 5 | {% assets "all-css" %}{% endassets %} 6 | {% endblock %} 7 | 8 | 9 | {% block body %} 10 | {% endblock %} 11 | 12 | 13 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | DEBUG = True 2 | SECRET_KEY = 'secret' 3 | 4 | 5 | # flask-assets 6 | # ------------ 7 | ASSETS_DEST = 'application/static' 8 | 9 | 10 | # flask-security 11 | # -------------- 12 | SECURITY_PASSWORD_HASH = 'bcrypt' 13 | SECURITY_PASSWORD_SALT = '$2a$10$WyxRXkzAICMHgmqhMGTlJu' 14 | SECURITY_CONFIRMABLE = False 15 | SECURITY_REGISTERABLE = True 16 | 17 | 18 | # flask-sqlalchemy 19 | # ---------------- 20 | SQLALCHEMY_DATABASE_URI = 'sqlite:///database.sql' 21 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | from flask.ext.script import Manager 2 | import flask.ext.security.script as sec 3 | from application import app, db 4 | 5 | manager = Manager(app) 6 | manager.add_command('create_user', sec.CreateUserCommand()) 7 | manager.add_command('create_role', sec.CreateRoleCommand()) 8 | manager.add_command('add_role', sec.AddRoleCommand()) 9 | manager.add_command('remove_role', sec.RemoveRoleCommand()) 10 | 11 | 12 | @manager.command 13 | def create_db(): 14 | """Creates database from sqlalchemy schema.""" 15 | db.create_all() 16 | 17 | 18 | def main(): 19 | manager.run() 20 | 21 | 22 | if __name__ == '__main__': 23 | main() 24 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | flask-admin 3 | flask-assets 4 | flask-debugtoolbar 5 | flask-markdown 6 | flask-script 7 | flask-security 8 | flask-sqlalchemy 9 | flask-wtf 10 | -------------------------------------------------------------------------------- /runserver.py: -------------------------------------------------------------------------------- 1 | from application import app, db 2 | 3 | db.create_all() 4 | app.run() 5 | -------------------------------------------------------------------------------- /seed.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | seed 4 | ~~~~ 5 | 6 | Add a lot of content to the forum. 7 | 8 | :license: MIT and BSD 9 | """ 10 | 11 | import random 12 | 13 | from application import db 14 | from application.models import * 15 | 16 | NUM_BOARDS = 1 17 | NUM_THREADS = 100 18 | NUM_POSTS = 20 19 | 20 | 21 | def main(): 22 | users = User.query.all() 23 | 24 | Board.query.delete() 25 | Thread.query.delete() 26 | Post.query.delete() 27 | 28 | for b in range(NUM_BOARDS): 29 | board = Board( 30 | name='Board %s' % b, 31 | slug='board-%s' % b, 32 | description='This is board number %s.' % b 33 | ) 34 | db.session.add(board) 35 | db.session.flush() 36 | for t in range(NUM_THREADS): 37 | author_id = random.choice(users).id 38 | thread = Thread( 39 | name='Thread %s' % t, 40 | author_id=author_id, 41 | board_id=board.id 42 | ) 43 | db.session.add(thread) 44 | db.session.flush() 45 | for p in range(NUM_POSTS): 46 | author_id = random.choice(users).id 47 | post = Post( 48 | content='This is post number %s.' % p, 49 | author_id=author_id, 50 | thread_id=thread.id 51 | ) 52 | thread.posts.append(post) 53 | 54 | db.session.commit() 55 | 56 | if __name__ == '__main__': 57 | main() 58 | -------------------------------------------------------------------------------- /test_forum.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | forum_tests.py 4 | ~~~~~~~~~~~~~~ 5 | 6 | Some basic tests on the forum app. 7 | 8 | TODO: test models, authentication 9 | 10 | :license: MIT and BSD 11 | """ 12 | 13 | import application 14 | from application.models import * 15 | 16 | ADMIN = 'admin@example.com' 17 | USER = 'admin@example.com' 18 | 19 | 20 | class Test(object): 21 | 22 | def setup(self): 23 | application.app.config.update({ 24 | 'TESTING': True, 25 | 'DEBUG_TB_ENABLED': False, 26 | 'SQLALCHEMY_DATABASE_URI': 'sqlite://' 27 | }) 28 | self.app = application.app.test_client() 29 | application.db.create_all() 30 | 31 | def teardown(self): 32 | pass 33 | 34 | def assert_code(self, path, code): 35 | response = self.app.get(path) 36 | assert response.status_code == code 37 | 38 | def assert_redirect(self, path, location=None): 39 | response = self.app.get(path) 40 | assert response.status_code == 302 41 | if location: 42 | assert response.location == 'http://localhost' + location 43 | 44 | def test_basic_response_codes(self): 45 | self.assert_code('/', 200) 46 | self.assert_code('/unknown', 404) 47 | 48 | def test_auth_response_codes(self): 49 | self.assert_code('/login', 200) 50 | self.assert_code('/logout', 302) 51 | self.assert_code('/register', 200) 52 | 53 | def check_admin_endpoints(self, code): 54 | self.assert_code('/admin/', code) 55 | for item in ['board', 'thread', 'post', 'user', 'role']: 56 | url = '/admin/%sview/' % item 57 | self.assert_code(url, code) 58 | self.assert_code(url + 'new/', code) 59 | 60 | def test_admin_response_codes(self): 61 | self.check_admin_endpoints(404) 62 | 63 | 64 | def test_forum_response_codes(self): 65 | self.assert_code('/forum/', 200) 66 | self.assert_redirect('/forum/board/', '/forum/') 67 | self.assert_redirect('/forum/board/1', '/forum/') 68 | self.assert_redirect('/forum/board/1-thread', '/forum/') 69 | 70 | # These redirect to the login page 71 | self.assert_redirect('/forum/board/1/create') 72 | self.assert_redirect('/forum/board/1/1/edit') 73 | --------------------------------------------------------------------------------