Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: miguelgrinberg/flasky
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: master
Choose a base ref
...
head repository: jinlinzhao/flasky
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: master
Choose a head ref
Can’t automatically merge. Don’t worry, you can still create the pull request.

Commits on Jun 22, 2018

  1. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    0142419 View commit details
  2. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    44cd5a4 View commit details
  3. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    240f745 View commit details
  4. 6

    Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    80da3d3 View commit details
  5. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    be0634f View commit details
  6. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    be88ca9 View commit details
  7. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    2bb472e View commit details
  8. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    07d50e6 View commit details
  9. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    d5c5154 View commit details
  10. 2

    Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    38616f8 View commit details
  11. 3

    Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    03edb4c View commit details
  12. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    90112a2 View commit details
  13. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    e78bd62 View commit details
  14. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    abb76cb View commit details
  15. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    036bae9 View commit details
  16. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    284b593 View commit details
  17. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    d1db353 View commit details
  18. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    3735243 View commit details
  19. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    a438c8d View commit details
  20. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    e14262e View commit details
  21. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    964b99a View commit details
  22. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    f13129a View commit details
  23. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    fc0bd2e View commit details
  24. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    5450ea3 View commit details
  25. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    0ae58a5 View commit details
  26. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    d92154d View commit details
  27. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    c48bc15 View commit details
  28. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    af584e0 View commit details
  29. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    5864dcd View commit details
  30. Chapter 14: API (14a)

    miguelgrinberg committed Jun 22, 2018

    Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    45290e4 View commit details
  31. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    3140671 View commit details
  32. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    852cbc9 View commit details
  33. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    506ce8b View commit details
  34. 2

    Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    bc7b413 View commit details
  35. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    5949924 View commit details
  36. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    6f6473b View commit details
  37. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    16ea38a View commit details
  38. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    a791252 View commit details
  39. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    4b18b2c View commit details
  40. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    a346edd View commit details
  41. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    926382d View commit details
  42. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    fc4cd84 View commit details
  43. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    e457949 View commit details
  44. Verified

    This commit was signed with the committer’s verified signature.
    miguelgrinberg Miguel Grinberg
    Copy the full SHA
    83d3614 View commit details
Showing with 3,259 additions and 173 deletions.
  1. +4 −0 .gitignore
  2. +21 −0 Dockerfile
  3. +1 −0 Procfile
  4. +45 −0 app/__init__.py
  5. +5 −0 app/api/__init__.py
  6. +44 −0 app/api/authentication.py
  7. +67 −0 app/api/comments.py
  8. +14 −0 app/api/decorators.py
  9. +26 −0 app/api/errors.py
  10. +57 −0 app/api/posts.py
  11. +53 −0 app/api/users.py
  12. +5 −0 app/auth/__init__.py
  13. +68 −0 app/auth/forms.py
  14. +169 −0 app/auth/views.py
  15. +19 −0 app/decorators.py
  16. +20 −0 app/email.py
  17. +2 −0 app/exceptions.py
  18. +37 −0 app/fake.py
  19. +11 −0 app/main/__init__.py
  20. +32 −0 app/main/errors.py
  21. +61 −0 app/main/forms.py
  22. +278 −0 app/main/views.py
  23. +367 −0 app/models.py
  24. BIN { → app}/static/favicon.ico
  25. +107 −0 app/static/styles.css
  26. +9 −0 app/templates/403.html
  27. 0 { → app}/templates/404.html
  28. 0 { → app}/templates/500.html
  29. +35 −0 app/templates/_comments.html
  30. +29 −0 app/templates/_macros.html
  31. +39 −0 app/templates/_posts.html
  32. +13 −0 app/templates/auth/change_email.html
  33. +13 −0 app/templates/auth/change_password.html
  34. +7 −0 app/templates/auth/email/change_email.html
  35. +11 −0 app/templates/auth/email/change_email.txt
  36. +8 −0 app/templates/auth/email/confirm.html
  37. +13 −0 app/templates/auth/email/confirm.txt
  38. +8 −0 app/templates/auth/email/reset_password.html
  39. +13 −0 app/templates/auth/email/reset_password.txt
  40. +16 −0 app/templates/auth/login.html
  41. +13 −0 app/templates/auth/register.html
  42. +13 −0 app/templates/auth/reset_password.html
  43. +20 −0 app/templates/auth/unconfirmed.html
  44. +72 −0 app/templates/base.html
  45. +18 −0 app/templates/edit_post.html
  46. +13 −0 app/templates/edit_profile.html
  47. +29 −0 app/templates/followers.html
  48. +35 −0 app/templates/index.html
  49. 0 { → app}/templates/mail/new_user.html
  50. 0 { → app}/templates/mail/new_user.txt
  51. +17 −0 app/templates/moderate.html
  52. +21 −0 app/templates/post.html
  53. +56 −0 app/templates/user.html
  54. +13 −0 boot.sh
  55. +124 −0 config.py
  56. +14 −0 docker-compose.yml
  57. +78 −0 flasky.py
  58. +0 −109 hello.py
  59. +26 −0 migrations/versions/190163627111_account_confirmation.py
  60. +26 −0 migrations/versions/198b0eebcf9_caching_of_avatar_hashes.py
  61. +35 −0 migrations/versions/1b966e7f4b9e_post_model.py
  62. +33 −0 migrations/versions/2356a38169ea_followers.py
  63. +26 −0 migrations/versions/288cd3dc5a8_rich_text_posts.py
  64. +30 −0 migrations/versions/456a945560f6_login_support.py
  65. +39 −0 migrations/versions/51f5ccfba190_comments.py
  66. +30 −0 migrations/versions/56ed7d33de8d_user_roles.py
  67. +34 −0 migrations/versions/d66f086b258_user_information.py
  68. +3 −0 requirements.txt
  69. +30 −0 requirements/common.txt
  70. +11 −0 requirements/dev.txt
  71. +3 −0 requirements/docker.txt
  72. +4 −0 requirements/heroku.txt
  73. +1 −0 requirements/prod.txt
  74. +0 −48 templates/base.html
  75. +0 −16 templates/index.html
  76. 0 tests/__init__.py
  77. +264 −0 tests/test_api.py
  78. +22 −0 tests/test_basics.py
  79. +62 −0 tests/test_client.py
  80. +98 −0 tests/test_selenium.py
  81. +219 −0 tests/test_user_model.py
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -40,3 +40,7 @@ nosetests.xml

# Virtual environment
venv

# Environment files
.env
.env-mysql
21 changes: 21 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
FROM python:3.6-alpine

ENV FLASK_APP flasky.py
ENV FLASK_CONFIG production

RUN adduser -D flasky
USER flasky

WORKDIR /home/flasky

COPY requirements requirements
RUN python -m venv venv
RUN venv/bin/pip install -r requirements/docker.txt

COPY app app
COPY migrations migrations
COPY flasky.py config.py boot.sh ./

# run-time configuration
EXPOSE 5000
ENTRYPOINT ["./boot.sh"]
1 change: 1 addition & 0 deletions Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web: gunicorn flasky:app
45 changes: 45 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from flask import Flask
from flask_bootstrap import Bootstrap
from flask_mail import Mail
from flask_moment import Moment
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_pagedown import PageDown
from config import config

bootstrap = Bootstrap()
mail = Mail()
moment = Moment()
db = SQLAlchemy()
pagedown = PageDown()

login_manager = LoginManager()
login_manager.login_view = 'auth.login'


def create_app(config_name):
app = Flask(__name__)
app.config.from_object(config[config_name])
config[config_name].init_app(app)

bootstrap.init_app(app)
mail.init_app(app)
moment.init_app(app)
db.init_app(app)
login_manager.init_app(app)
pagedown.init_app(app)

if app.config['SSL_REDIRECT']:
from flask_sslify import SSLify
sslify = SSLify(app)

from .main import main as main_blueprint
app.register_blueprint(main_blueprint)

from .auth import auth as auth_blueprint
app.register_blueprint(auth_blueprint, url_prefix='/auth')

from .api import api as api_blueprint
app.register_blueprint(api_blueprint, url_prefix='/api/v1')

return app
5 changes: 5 additions & 0 deletions app/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from flask import Blueprint

api = Blueprint('api', __name__)

from . import authentication, posts, users, comments, errors
44 changes: 44 additions & 0 deletions app/api/authentication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from flask import g, jsonify
from flask_httpauth import HTTPBasicAuth
from ..models import User
from . import api
from .errors import unauthorized, forbidden

auth = HTTPBasicAuth()


@auth.verify_password
def verify_password(email_or_token, password):
if email_or_token == '':
return False
if password == '':
g.current_user = User.verify_auth_token(email_or_token)
g.token_used = True
return g.current_user is not None
user = User.query.filter_by(email=email_or_token).first()
if not user:
return False
g.current_user = user
g.token_used = False
return user.verify_password(password)


@auth.error_handler
def auth_error():
return unauthorized('Invalid credentials')


@api.before_request
@auth.login_required
def before_request():
if not g.current_user.is_anonymous and \
not g.current_user.confirmed:
return forbidden('Unconfirmed account')


@api.route('/tokens/', methods=['POST'])
def get_token():
if g.current_user.is_anonymous or g.token_used:
return unauthorized('Invalid credentials')
return jsonify({'token': g.current_user.generate_auth_token(
expiration=3600), 'expiration': 3600})
67 changes: 67 additions & 0 deletions app/api/comments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from flask import jsonify, request, g, url_for, current_app
from .. import db
from ..models import Post, Permission, Comment
from . import api
from .decorators import permission_required


@api.route('/comments/')
def get_comments():
page = request.args.get('page', 1, type=int)
pagination = Comment.query.order_by(Comment.timestamp.desc()).paginate(
page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'],
error_out=False)
comments = pagination.items
prev = None
if pagination.has_prev:
prev = url_for('api.get_comments', page=page-1)
next = None
if pagination.has_next:
next = url_for('api.get_comments', page=page+1)
return jsonify({
'comments': [comment.to_json() for comment in comments],
'prev': prev,
'next': next,
'count': pagination.total
})


@api.route('/comments/<int:id>')
def get_comment(id):
comment = Comment.query.get_or_404(id)
return jsonify(comment.to_json())


@api.route('/posts/<int:id>/comments/')
def get_post_comments(id):
post = Post.query.get_or_404(id)
page = request.args.get('page', 1, type=int)
pagination = post.comments.order_by(Comment.timestamp.asc()).paginate(
page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'],
error_out=False)
comments = pagination.items
prev = None
if pagination.has_prev:
prev = url_for('api.get_post_comments', id=id, page=page-1)
next = None
if pagination.has_next:
next = url_for('api.get_post_comments', id=id, page=page+1)
return jsonify({
'comments': [comment.to_json() for comment in comments],
'prev': prev,
'next': next,
'count': pagination.total
})


@api.route('/posts/<int:id>/comments/', methods=['POST'])
@permission_required(Permission.COMMENT)
def new_post_comment(id):
post = Post.query.get_or_404(id)
comment = Comment.from_json(request.json)
comment.author = g.current_user
comment.post = post
db.session.add(comment)
db.session.commit()
return jsonify(comment.to_json()), 201, \
{'Location': url_for('api.get_comment', id=comment.id)}
14 changes: 14 additions & 0 deletions app/api/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from functools import wraps
from flask import g
from .errors import forbidden


def permission_required(permission):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not g.current_user.can(permission):
return forbidden('Insufficient permissions')
return f(*args, **kwargs)
return decorated_function
return decorator
26 changes: 26 additions & 0 deletions app/api/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from flask import jsonify
from app.exceptions import ValidationError
from . import api


def bad_request(message):
response = jsonify({'error': 'bad request', 'message': message})
response.status_code = 400
return response


def unauthorized(message):
response = jsonify({'error': 'unauthorized', 'message': message})
response.status_code = 401
return response


def forbidden(message):
response = jsonify({'error': 'forbidden', 'message': message})
response.status_code = 403
return response


@api.errorhandler(ValidationError)
def validation_error(e):
return bad_request(e.args[0])
57 changes: 57 additions & 0 deletions app/api/posts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from flask import jsonify, request, g, url_for, current_app
from .. import db
from ..models import Post, Permission
from . import api
from .decorators import permission_required
from .errors import forbidden


@api.route('/posts/')
def get_posts():
page = request.args.get('page', 1, type=int)
pagination = Post.query.paginate(
page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
error_out=False)
posts = pagination.items
prev = None
if pagination.has_prev:
prev = url_for('api.get_posts', page=page-1)
next = None
if pagination.has_next:
next = url_for('api.get_posts', page=page+1)
return jsonify({
'posts': [post.to_json() for post in posts],
'prev': prev,
'next': next,
'count': pagination.total
})


@api.route('/posts/<int:id>')
def get_post(id):
post = Post.query.get_or_404(id)
return jsonify(post.to_json())


@api.route('/posts/', methods=['POST'])
@permission_required(Permission.WRITE)
def new_post():
post = Post.from_json(request.json)
post.author = g.current_user
db.session.add(post)
db.session.commit()
return jsonify(post.to_json()), 201, \
{'Location': url_for('api.get_post', id=post.id)}


@api.route('/posts/<int:id>', methods=['PUT'])
@permission_required(Permission.WRITE)
def edit_post(id):
post = Post.query.get_or_404(id)
if g.current_user != post.author and \
not g.current_user.can(Permission.ADMIN):
return forbidden('Insufficient permissions')
post.body = request.json.get('body', post.body)
db.session.add(post)
db.session.commit()
return jsonify(post.to_json())
53 changes: 53 additions & 0 deletions app/api/users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from flask import jsonify, request, current_app, url_for
from . import api
from ..models import User, Post


@api.route('/users/<int:id>')
def get_user(id):
user = User.query.get_or_404(id)
return jsonify(user.to_json())


@api.route('/users/<int:id>/posts/')
def get_user_posts(id):
user = User.query.get_or_404(id)
page = request.args.get('page', 1, type=int)
pagination = user.posts.order_by(Post.timestamp.desc()).paginate(
page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
error_out=False)
posts = pagination.items
prev = None
if pagination.has_prev:
prev = url_for('api.get_user_posts', id=id, page=page-1)
next = None
if pagination.has_next:
next = url_for('api.get_user_posts', id=id, page=page+1)
return jsonify({
'posts': [post.to_json() for post in posts],
'prev': prev,
'next': next,
'count': pagination.total
})


@api.route('/users/<int:id>/timeline/')
def get_user_followed_posts(id):
user = User.query.get_or_404(id)
page = request.args.get('page', 1, type=int)
pagination = user.followed_posts.order_by(Post.timestamp.desc()).paginate(
page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
error_out=False)
posts = pagination.items
prev = None
if pagination.has_prev:
prev = url_for('api.get_user_followed_posts', id=id, page=page-1)
next = None
if pagination.has_next:
next = url_for('api.get_user_followed_posts', id=id, page=page+1)
return jsonify({
'posts': [post.to_json() for post in posts],
'prev': prev,
'next': next,
'count': pagination.total
})
5 changes: 5 additions & 0 deletions app/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from flask import Blueprint

auth = Blueprint('auth', __name__)

from . import views
68 changes: 68 additions & 0 deletions app/auth/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length, Email, Regexp, EqualTo
from wtforms import ValidationError
from ..models import User


class LoginForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Length(1, 64),
Email()])
password = PasswordField('Password', validators=[DataRequired()])
remember_me = BooleanField('Keep me logged in')
submit = SubmitField('Log In')


class RegistrationForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Length(1, 64),
Email()])
username = StringField('Username', validators=[
DataRequired(), Length(1, 64),
Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0,
'Usernames must have only letters, numbers, dots or '
'underscores')])
password = PasswordField('Password', validators=[
DataRequired(), EqualTo('password2', message='Passwords must match.')])
password2 = PasswordField('Confirm password', validators=[DataRequired()])
submit = SubmitField('Register')

def validate_email(self, field):
if User.query.filter_by(email=field.data).first():
raise ValidationError('Email already registered.')

def validate_username(self, field):
if User.query.filter_by(username=field.data).first():
raise ValidationError('Username already in use.')


class ChangePasswordForm(FlaskForm):
old_password = PasswordField('Old password', validators=[DataRequired()])
password = PasswordField('New password', validators=[
DataRequired(), EqualTo('password2', message='Passwords must match.')])
password2 = PasswordField('Confirm new password',
validators=[DataRequired()])
submit = SubmitField('Update Password')


class PasswordResetRequestForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Length(1, 64),
Email()])
submit = SubmitField('Reset Password')


class PasswordResetForm(FlaskForm):
password = PasswordField('New Password', validators=[
DataRequired(), EqualTo('password2', message='Passwords must match')])
password2 = PasswordField('Confirm password', validators=[DataRequired()])
submit = SubmitField('Reset Password')


class ChangeEmailForm(FlaskForm):
email = StringField('New Email', validators=[DataRequired(), Length(1, 64),
Email()])
password = PasswordField('Password', validators=[DataRequired()])
submit = SubmitField('Update Email Address')

def validate_email(self, field):
if User.query.filter_by(email=field.data).first():
raise ValidationError('Email already registered.')
169 changes: 169 additions & 0 deletions app/auth/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
from flask import render_template, redirect, request, url_for, flash
from flask_login import login_user, logout_user, login_required, \
current_user
from . import auth
from .. import db
from ..models import User
from ..email import send_email
from .forms import LoginForm, RegistrationForm, ChangePasswordForm,\
PasswordResetRequestForm, PasswordResetForm, ChangeEmailForm


@auth.before_app_request
def before_request():
if current_user.is_authenticated:
current_user.ping()
if not current_user.confirmed \
and request.endpoint \
and request.blueprint != 'auth' \
and request.endpoint != 'static':
return redirect(url_for('auth.unconfirmed'))


@auth.route('/unconfirmed')
def unconfirmed():
if current_user.is_anonymous or current_user.confirmed:
return redirect(url_for('main.index'))
return render_template('auth/unconfirmed.html')


@auth.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user is not None and user.verify_password(form.password.data):
login_user(user, form.remember_me.data)
next = request.args.get('next')
if next is None or not next.startswith('/'):
next = url_for('main.index')
return redirect(next)
flash('Invalid username or password.')
return render_template('auth/login.html', form=form)


@auth.route('/logout')
@login_required
def logout():
logout_user()
flash('You have been logged out.')
return redirect(url_for('main.index'))


@auth.route('/register', methods=['GET', 'POST'])
def register():
form = RegistrationForm()
if form.validate_on_submit():
user = User(email=form.email.data,
username=form.username.data,
password=form.password.data)
db.session.add(user)
db.session.commit()
token = user.generate_confirmation_token()
send_email(user.email, 'Confirm Your Account',
'auth/email/confirm', user=user, token=token)
flash('A confirmation email has been sent to you by email.')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', form=form)


@auth.route('/confirm/<token>')
@login_required
def confirm(token):
if current_user.confirmed:
return redirect(url_for('main.index'))
if current_user.confirm(token):
db.session.commit()
flash('You have confirmed your account. Thanks!')
else:
flash('The confirmation link is invalid or has expired.')
return redirect(url_for('main.index'))


@auth.route('/confirm')
@login_required
def resend_confirmation():
token = current_user.generate_confirmation_token()
send_email(current_user.email, 'Confirm Your Account',
'auth/email/confirm', user=current_user, token=token)
flash('A new confirmation email has been sent to you by email.')
return redirect(url_for('main.index'))


@auth.route('/change-password', methods=['GET', 'POST'])
@login_required
def change_password():
form = ChangePasswordForm()
if form.validate_on_submit():
if current_user.verify_password(form.old_password.data):
current_user.password = form.password.data
db.session.add(current_user)
db.session.commit()
flash('Your password has been updated.')
return redirect(url_for('main.index'))
else:
flash('Invalid password.')
return render_template("auth/change_password.html", form=form)


@auth.route('/reset', methods=['GET', 'POST'])
def password_reset_request():
if not current_user.is_anonymous:
return redirect(url_for('main.index'))
form = PasswordResetRequestForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user:
token = user.generate_reset_token()
send_email(user.email, 'Reset Your Password',
'auth/email/reset_password',
user=user, token=token)
flash('An email with instructions to reset your password has been '
'sent to you.')
return redirect(url_for('auth.login'))
return render_template('auth/reset_password.html', form=form)


@auth.route('/reset/<token>', methods=['GET', 'POST'])
def password_reset(token):
if not current_user.is_anonymous:
return redirect(url_for('main.index'))
form = PasswordResetForm()
if form.validate_on_submit():
if User.reset_password(token, form.password.data):
db.session.commit()
flash('Your password has been updated.')
return redirect(url_for('auth.login'))
else:
return redirect(url_for('main.index'))
return render_template('auth/reset_password.html', form=form)


@auth.route('/change_email', methods=['GET', 'POST'])
@login_required
def change_email_request():
form = ChangeEmailForm()
if form.validate_on_submit():
if current_user.verify_password(form.password.data):
new_email = form.email.data
token = current_user.generate_email_change_token(new_email)
send_email(new_email, 'Confirm your email address',
'auth/email/change_email',
user=current_user, token=token)
flash('An email with instructions to confirm your new email '
'address has been sent to you.')
return redirect(url_for('main.index'))
else:
flash('Invalid email or password.')
return render_template("auth/change_email.html", form=form)


@auth.route('/change_email/<token>')
@login_required
def change_email(token):
if current_user.change_email(token):
db.session.commit()
flash('Your email address has been updated.')
else:
flash('Invalid request.')
return redirect(url_for('main.index'))
19 changes: 19 additions & 0 deletions app/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from functools import wraps
from flask import abort
from flask_login import current_user
from .models import Permission


def permission_required(permission):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.can(permission):
abort(403)
return f(*args, **kwargs)
return decorated_function
return decorator


def admin_required(f):
return permission_required(Permission.ADMIN)(f)
20 changes: 20 additions & 0 deletions app/email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from threading import Thread
from flask import current_app, render_template
from flask_mail import Message
from . import mail


def send_async_email(app, msg):
with app.app_context():
mail.send(msg)


def send_email(to, subject, template, **kwargs):
app = current_app._get_current_object()
msg = Message(app.config['FLASKY_MAIL_SUBJECT_PREFIX'] + ' ' + subject,
sender=app.config['FLASKY_MAIL_SENDER'], recipients=[to])
msg.body = render_template(template + '.txt', **kwargs)
msg.html = render_template(template + '.html', **kwargs)
thr = Thread(target=send_async_email, args=[app, msg])
thr.start()
return thr
2 changes: 2 additions & 0 deletions app/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class ValidationError(ValueError):
pass
37 changes: 37 additions & 0 deletions app/fake.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from random import randint
from sqlalchemy.exc import IntegrityError
from faker import Faker
from . import db
from .models import User, Post


def users(count=100):
fake = Faker()
i = 0
while i < count:
u = User(email=fake.email(),
username=fake.user_name(),
password='password',
confirmed=True,
name=fake.name(),
location=fake.city(),
about_me=fake.text(),
member_since=fake.past_date())
db.session.add(u)
try:
db.session.commit()
i += 1
except IntegrityError:
db.session.rollback()


def posts(count=100):
fake = Faker()
user_count = User.query.count()
for i in range(count):
u = User.query.offset(randint(0, user_count - 1)).first()
p = Post(body=fake.text(),
timestamp=fake.past_date(),
author=u)
db.session.add(p)
db.session.commit()
11 changes: 11 additions & 0 deletions app/main/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from flask import Blueprint

main = Blueprint('main', __name__)

from . import views, errors
from ..models import Permission


@main.app_context_processor
def inject_permissions():
return dict(Permission=Permission)
32 changes: 32 additions & 0 deletions app/main/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from flask import render_template, request, jsonify
from . import main


@main.app_errorhandler(403)
def forbidden(e):
if request.accept_mimetypes.accept_json and \
not request.accept_mimetypes.accept_html:
response = jsonify({'error': 'forbidden'})
response.status_code = 403
return response
return render_template('403.html'), 403


@main.app_errorhandler(404)
def page_not_found(e):
if request.accept_mimetypes.accept_json and \
not request.accept_mimetypes.accept_html:
response = jsonify({'error': 'not found'})
response.status_code = 404
return response
return render_template('404.html'), 404


@main.app_errorhandler(500)
def internal_server_error(e):
if request.accept_mimetypes.accept_json and \
not request.accept_mimetypes.accept_html:
response = jsonify({'error': 'internal server error'})
response.status_code = 500
return response
return render_template('500.html'), 500
61 changes: 61 additions & 0 deletions app/main/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, BooleanField, SelectField,\
SubmitField
from wtforms.validators import DataRequired, Length, Email, Regexp
from wtforms import ValidationError
from flask_pagedown.fields import PageDownField
from ..models import Role, User


class NameForm(FlaskForm):
name = StringField('What is your name?', validators=[DataRequired()])
submit = SubmitField('Submit')


class EditProfileForm(FlaskForm):
name = StringField('Real name', validators=[Length(0, 64)])
location = StringField('Location', validators=[Length(0, 64)])
about_me = TextAreaField('About me')
submit = SubmitField('Submit')


class EditProfileAdminForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Length(1, 64),
Email()])
username = StringField('Username', validators=[
DataRequired(), Length(1, 64),
Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0,
'Usernames must have only letters, numbers, dots or '
'underscores')])
confirmed = BooleanField('Confirmed')
role = SelectField('Role', coerce=int)
name = StringField('Real name', validators=[Length(0, 64)])
location = StringField('Location', validators=[Length(0, 64)])
about_me = TextAreaField('About me')
submit = SubmitField('Submit')

def __init__(self, user, *args, **kwargs):
super(EditProfileAdminForm, self).__init__(*args, **kwargs)
self.role.choices = [(role.id, role.name)
for role in Role.query.order_by(Role.name).all()]
self.user = user

def validate_email(self, field):
if field.data != self.user.email and \
User.query.filter_by(email=field.data).first():
raise ValidationError('Email already registered.')

def validate_username(self, field):
if field.data != self.user.username and \
User.query.filter_by(username=field.data).first():
raise ValidationError('Username already in use.')


class PostForm(FlaskForm):
body = PageDownField("What's on your mind?", validators=[DataRequired()])
submit = SubmitField('Submit')


class CommentForm(FlaskForm):
body = StringField('Enter your comment', validators=[DataRequired()])
submit = SubmitField('Submit')
278 changes: 278 additions & 0 deletions app/main/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
from flask import render_template, redirect, url_for, abort, flash, request,\
current_app, make_response
from flask_login import login_required, current_user
from flask_sqlalchemy import get_debug_queries
from . import main
from .forms import EditProfileForm, EditProfileAdminForm, PostForm,\
CommentForm
from .. import db
from ..models import Permission, Role, User, Post, Comment
from ..decorators import admin_required, permission_required


@main.after_app_request
def after_request(response):
for query in get_debug_queries():
if query.duration >= current_app.config['FLASKY_SLOW_DB_QUERY_TIME']:
current_app.logger.warning(
'Slow query: %s\nParameters: %s\nDuration: %fs\nContext: %s\n'
% (query.statement, query.parameters, query.duration,
query.context))
return response


@main.route('/shutdown')
def server_shutdown():
if not current_app.testing:
abort(404)
shutdown = request.environ.get('werkzeug.server.shutdown')
if not shutdown:
abort(500)
shutdown()
return 'Shutting down...'


@main.route('/', methods=['GET', 'POST'])
def index():
form = PostForm()
if current_user.can(Permission.WRITE) and form.validate_on_submit():
post = Post(body=form.body.data,
author=current_user._get_current_object())
db.session.add(post)
db.session.commit()
return redirect(url_for('.index'))
page = request.args.get('page', 1, type=int)
show_followed = False
if current_user.is_authenticated:
show_followed = bool(request.cookies.get('show_followed', ''))
if show_followed:
query = current_user.followed_posts
else:
query = Post.query
pagination = query.order_by(Post.timestamp.desc()).paginate(
page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
error_out=False)
posts = pagination.items
return render_template('index.html', form=form, posts=posts,
show_followed=show_followed, pagination=pagination)


@main.route('/user/<username>')
def user(username):
user = User.query.filter_by(username=username).first_or_404()
page = request.args.get('page', 1, type=int)
pagination = user.posts.order_by(Post.timestamp.desc()).paginate(
page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
error_out=False)
posts = pagination.items
return render_template('user.html', user=user, posts=posts,
pagination=pagination)


@main.route('/edit-profile', methods=['GET', 'POST'])
@login_required
def edit_profile():
form = EditProfileForm()
if form.validate_on_submit():
current_user.name = form.name.data
current_user.location = form.location.data
current_user.about_me = form.about_me.data
db.session.add(current_user._get_current_object())
db.session.commit()
flash('Your profile has been updated.')
return redirect(url_for('.user', username=current_user.username))
form.name.data = current_user.name
form.location.data = current_user.location
form.about_me.data = current_user.about_me
return render_template('edit_profile.html', form=form)


@main.route('/edit-profile/<int:id>', methods=['GET', 'POST'])
@login_required
@admin_required
def edit_profile_admin(id):
user = User.query.get_or_404(id)
form = EditProfileAdminForm(user=user)
if form.validate_on_submit():
user.email = form.email.data
user.username = form.username.data
user.confirmed = form.confirmed.data
user.role = Role.query.get(form.role.data)
user.name = form.name.data
user.location = form.location.data
user.about_me = form.about_me.data
db.session.add(user)
db.session.commit()
flash('The profile has been updated.')
return redirect(url_for('.user', username=user.username))
form.email.data = user.email
form.username.data = user.username
form.confirmed.data = user.confirmed
form.role.data = user.role_id
form.name.data = user.name
form.location.data = user.location
form.about_me.data = user.about_me
return render_template('edit_profile.html', form=form, user=user)


@main.route('/post/<int:id>', methods=['GET', 'POST'])
def post(id):
post = Post.query.get_or_404(id)
form = CommentForm()
if form.validate_on_submit():
comment = Comment(body=form.body.data,
post=post,
author=current_user._get_current_object())
db.session.add(comment)
db.session.commit()
flash('Your comment has been published.')
return redirect(url_for('.post', id=post.id, page=-1))
page = request.args.get('page', 1, type=int)
if page == -1:
page = (post.comments.count() - 1) // \
current_app.config['FLASKY_COMMENTS_PER_PAGE'] + 1
pagination = post.comments.order_by(Comment.timestamp.asc()).paginate(
page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'],
error_out=False)
comments = pagination.items
return render_template('post.html', posts=[post], form=form,
comments=comments, pagination=pagination)


@main.route('/edit/<int:id>', methods=['GET', 'POST'])
@login_required
def edit(id):
post = Post.query.get_or_404(id)
if current_user != post.author and \
not current_user.can(Permission.ADMIN):
abort(403)
form = PostForm()
if form.validate_on_submit():
post.body = form.body.data
db.session.add(post)
db.session.commit()
flash('The post has been updated.')
return redirect(url_for('.post', id=post.id))
form.body.data = post.body
return render_template('edit_post.html', form=form)


@main.route('/follow/<username>')
@login_required
@permission_required(Permission.FOLLOW)
def follow(username):
user = User.query.filter_by(username=username).first()
if user is None:
flash('Invalid user.')
return redirect(url_for('.index'))
if current_user.is_following(user):
flash('You are already following this user.')
return redirect(url_for('.user', username=username))
current_user.follow(user)
db.session.commit()
flash('You are now following %s.' % username)
return redirect(url_for('.user', username=username))


@main.route('/unfollow/<username>')
@login_required
@permission_required(Permission.FOLLOW)
def unfollow(username):
user = User.query.filter_by(username=username).first()
if user is None:
flash('Invalid user.')
return redirect(url_for('.index'))
if not current_user.is_following(user):
flash('You are not following this user.')
return redirect(url_for('.user', username=username))
current_user.unfollow(user)
db.session.commit()
flash('You are not following %s anymore.' % username)
return redirect(url_for('.user', username=username))


@main.route('/followers/<username>')
def followers(username):
user = User.query.filter_by(username=username).first()
if user is None:
flash('Invalid user.')
return redirect(url_for('.index'))
page = request.args.get('page', 1, type=int)
pagination = user.followers.paginate(
page, per_page=current_app.config['FLASKY_FOLLOWERS_PER_PAGE'],
error_out=False)
follows = [{'user': item.follower, 'timestamp': item.timestamp}
for item in pagination.items]
return render_template('followers.html', user=user, title="Followers of",
endpoint='.followers', pagination=pagination,
follows=follows)


@main.route('/followed_by/<username>')
def followed_by(username):
user = User.query.filter_by(username=username).first()
if user is None:
flash('Invalid user.')
return redirect(url_for('.index'))
page = request.args.get('page', 1, type=int)
pagination = user.followed.paginate(
page, per_page=current_app.config['FLASKY_FOLLOWERS_PER_PAGE'],
error_out=False)
follows = [{'user': item.followed, 'timestamp': item.timestamp}
for item in pagination.items]
return render_template('followers.html', user=user, title="Followed by",
endpoint='.followed_by', pagination=pagination,
follows=follows)


@main.route('/all')
@login_required
def show_all():
resp = make_response(redirect(url_for('.index')))
resp.set_cookie('show_followed', '', max_age=30*24*60*60)
return resp


@main.route('/followed')
@login_required
def show_followed():
resp = make_response(redirect(url_for('.index')))
resp.set_cookie('show_followed', '1', max_age=30*24*60*60)
return resp


@main.route('/moderate')
@login_required
@permission_required(Permission.MODERATE)
def moderate():
page = request.args.get('page', 1, type=int)
pagination = Comment.query.order_by(Comment.timestamp.desc()).paginate(
page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'],
error_out=False)
comments = pagination.items
return render_template('moderate.html', comments=comments,
pagination=pagination, page=page)


@main.route('/moderate/enable/<int:id>')
@login_required
@permission_required(Permission.MODERATE)
def moderate_enable(id):
comment = Comment.query.get_or_404(id)
comment.disabled = False
db.session.add(comment)
db.session.commit()
return redirect(url_for('.moderate',
page=request.args.get('page', 1, type=int)))


@main.route('/moderate/disable/<int:id>')
@login_required
@permission_required(Permission.MODERATE)
def moderate_disable(id):
comment = Comment.query.get_or_404(id)
comment.disabled = True
db.session.add(comment)
db.session.commit()
return redirect(url_for('.moderate',
page=request.args.get('page', 1, type=int)))
367 changes: 367 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,367 @@
from datetime import datetime
import hashlib
from werkzeug.security import generate_password_hash, check_password_hash
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
from markdown import markdown
import bleach
from flask import current_app, request, url_for
from flask_login import UserMixin, AnonymousUserMixin
from app.exceptions import ValidationError
from . import db, login_manager


class Permission:
FOLLOW = 1
COMMENT = 2
WRITE = 4
MODERATE = 8
ADMIN = 16


class Role(db.Model):
__tablename__ = 'roles'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True)
default = db.Column(db.Boolean, default=False, index=True)
permissions = db.Column(db.Integer)
users = db.relationship('User', backref='role', lazy='dynamic')

def __init__(self, **kwargs):
super(Role, self).__init__(**kwargs)
if self.permissions is None:
self.permissions = 0

@staticmethod
def insert_roles():
roles = {
'User': [Permission.FOLLOW, Permission.COMMENT, Permission.WRITE],
'Moderator': [Permission.FOLLOW, Permission.COMMENT,
Permission.WRITE, Permission.MODERATE],
'Administrator': [Permission.FOLLOW, Permission.COMMENT,
Permission.WRITE, Permission.MODERATE,
Permission.ADMIN],
}
default_role = 'User'
for r in roles:
role = Role.query.filter_by(name=r).first()
if role is None:
role = Role(name=r)
role.reset_permissions()
for perm in roles[r]:
role.add_permission(perm)
role.default = (role.name == default_role)
db.session.add(role)
db.session.commit()

def add_permission(self, perm):
if not self.has_permission(perm):
self.permissions += perm

def remove_permission(self, perm):
if self.has_permission(perm):
self.permissions -= perm

def reset_permissions(self):
self.permissions = 0

def has_permission(self, perm):
return self.permissions & perm == perm

def __repr__(self):
return '<Role %r>' % self.name


class Follow(db.Model):
__tablename__ = 'follows'
follower_id = db.Column(db.Integer, db.ForeignKey('users.id'),
primary_key=True)
followed_id = db.Column(db.Integer, db.ForeignKey('users.id'),
primary_key=True)
timestamp = db.Column(db.DateTime, default=datetime.utcnow)


class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(64), unique=True, index=True)
username = db.Column(db.String(64), unique=True, index=True)
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
password_hash = db.Column(db.String(128))
confirmed = db.Column(db.Boolean, default=False)
name = db.Column(db.String(64))
location = db.Column(db.String(64))
about_me = db.Column(db.Text())
member_since = db.Column(db.DateTime(), default=datetime.utcnow)
last_seen = db.Column(db.DateTime(), default=datetime.utcnow)
avatar_hash = db.Column(db.String(32))
posts = db.relationship('Post', backref='author', lazy='dynamic')
followed = db.relationship('Follow',
foreign_keys=[Follow.follower_id],
backref=db.backref('follower', lazy='joined'),
lazy='dynamic',
cascade='all, delete-orphan')
followers = db.relationship('Follow',
foreign_keys=[Follow.followed_id],
backref=db.backref('followed', lazy='joined'),
lazy='dynamic',
cascade='all, delete-orphan')
comments = db.relationship('Comment', backref='author', lazy='dynamic')

@staticmethod
def add_self_follows():
for user in User.query.all():
if not user.is_following(user):
user.follow(user)
db.session.add(user)
db.session.commit()

def __init__(self, **kwargs):
super(User, self).__init__(**kwargs)
if self.role is None:
if self.email == current_app.config['FLASKY_ADMIN']:
self.role = Role.query.filter_by(name='Administrator').first()
if self.role is None:
self.role = Role.query.filter_by(default=True).first()
if self.email is not None and self.avatar_hash is None:
self.avatar_hash = self.gravatar_hash()
self.follow(self)

@property
def password(self):
raise AttributeError('password is not a readable attribute')

@password.setter
def password(self, password):
self.password_hash = generate_password_hash(password)

def verify_password(self, password):
return check_password_hash(self.password_hash, password)

def generate_confirmation_token(self, expiration=3600):
s = Serializer(current_app.config['SECRET_KEY'], expiration)
return s.dumps({'confirm': self.id}).decode('utf-8')

def confirm(self, token):
s = Serializer(current_app.config['SECRET_KEY'])
try:
data = s.loads(token.encode('utf-8'))
except:
return False
if data.get('confirm') != self.id:
return False
self.confirmed = True
db.session.add(self)
return True

def generate_reset_token(self, expiration=3600):
s = Serializer(current_app.config['SECRET_KEY'], expiration)
return s.dumps({'reset': self.id}).decode('utf-8')

@staticmethod
def reset_password(token, new_password):
s = Serializer(current_app.config['SECRET_KEY'])
try:
data = s.loads(token.encode('utf-8'))
except:
return False
user = User.query.get(data.get('reset'))
if user is None:
return False
user.password = new_password
db.session.add(user)
return True

def generate_email_change_token(self, new_email, expiration=3600):
s = Serializer(current_app.config['SECRET_KEY'], expiration)
return s.dumps(
{'change_email': self.id, 'new_email': new_email}).decode('utf-8')

def change_email(self, token):
s = Serializer(current_app.config['SECRET_KEY'])
try:
data = s.loads(token.encode('utf-8'))
except:
return False
if data.get('change_email') != self.id:
return False
new_email = data.get('new_email')
if new_email is None:
return False
if self.query.filter_by(email=new_email).first() is not None:
return False
self.email = new_email
self.avatar_hash = self.gravatar_hash()
db.session.add(self)
return True

def can(self, perm):
return self.role is not None and self.role.has_permission(perm)

def is_administrator(self):
return self.can(Permission.ADMIN)

def ping(self):
self.last_seen = datetime.utcnow()
db.session.add(self)

def gravatar_hash(self):
return hashlib.md5(self.email.lower().encode('utf-8')).hexdigest()

def gravatar(self, size=100, default='identicon', rating='g'):
url = 'https://secure.gravatar.com/avatar'
hash = self.avatar_hash or self.gravatar_hash()
return '{url}/{hash}?s={size}&d={default}&r={rating}'.format(
url=url, hash=hash, size=size, default=default, rating=rating)

def follow(self, user):
if not self.is_following(user):
f = Follow(follower=self, followed=user)
db.session.add(f)

def unfollow(self, user):
f = self.followed.filter_by(followed_id=user.id).first()
if f:
db.session.delete(f)

def is_following(self, user):
if user.id is None:
return False
return self.followed.filter_by(
followed_id=user.id).first() is not None

def is_followed_by(self, user):
if user.id is None:
return False
return self.followers.filter_by(
follower_id=user.id).first() is not None

@property
def followed_posts(self):
return Post.query.join(Follow, Follow.followed_id == Post.author_id)\
.filter(Follow.follower_id == self.id)

def to_json(self):
json_user = {
'url': url_for('api.get_user', id=self.id),
'username': self.username,
'member_since': self.member_since,
'last_seen': self.last_seen,
'posts_url': url_for('api.get_user_posts', id=self.id),
'followed_posts_url': url_for('api.get_user_followed_posts',
id=self.id),
'post_count': self.posts.count()
}
return json_user

def generate_auth_token(self, expiration):
s = Serializer(current_app.config['SECRET_KEY'],
expires_in=expiration)
return s.dumps({'id': self.id}).decode('utf-8')

@staticmethod
def verify_auth_token(token):
s = Serializer(current_app.config['SECRET_KEY'])
try:
data = s.loads(token)
except:
return None
return User.query.get(data['id'])

def __repr__(self):
return '<User %r>' % self.username


class AnonymousUser(AnonymousUserMixin):
def can(self, permissions):
return False

def is_administrator(self):
return False

login_manager.anonymous_user = AnonymousUser


@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))


class Post(db.Model):
__tablename__ = 'posts'
id = db.Column(db.Integer, primary_key=True)
body = db.Column(db.Text)
body_html = db.Column(db.Text)
timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
author_id = db.Column(db.Integer, db.ForeignKey('users.id'))
comments = db.relationship('Comment', backref='post', lazy='dynamic')

@staticmethod
def on_changed_body(target, value, oldvalue, initiator):
allowed_tags = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code',
'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul',
'h1', 'h2', 'h3', 'p']
target.body_html = bleach.linkify(bleach.clean(
markdown(value, output_format='html'),
tags=allowed_tags, strip=True))

def to_json(self):
json_post = {
'url': url_for('api.get_post', id=self.id),
'body': self.body,
'body_html': self.body_html,
'timestamp': self.timestamp,
'author_url': url_for('api.get_user', id=self.author_id),
'comments_url': url_for('api.get_post_comments', id=self.id),
'comment_count': self.comments.count()
}
return json_post

@staticmethod
def from_json(json_post):
body = json_post.get('body')
if body is None or body == '':
raise ValidationError('post does not have a body')
return Post(body=body)


db.event.listen(Post.body, 'set', Post.on_changed_body)


class Comment(db.Model):
__tablename__ = 'comments'
id = db.Column(db.Integer, primary_key=True)
body = db.Column(db.Text)
body_html = db.Column(db.Text)
timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
disabled = db.Column(db.Boolean)
author_id = db.Column(db.Integer, db.ForeignKey('users.id'))
post_id = db.Column(db.Integer, db.ForeignKey('posts.id'))

@staticmethod
def on_changed_body(target, value, oldvalue, initiator):
allowed_tags = ['a', 'abbr', 'acronym', 'b', 'code', 'em', 'i',
'strong']
target.body_html = bleach.linkify(bleach.clean(
markdown(value, output_format='html'),
tags=allowed_tags, strip=True))

def to_json(self):
json_comment = {
'url': url_for('api.get_comment', id=self.id),
'post_url': url_for('api.get_post', id=self.post_id),
'body': self.body,
'body_html': self.body_html,
'timestamp': self.timestamp,
'author_url': url_for('api.get_user', id=self.author_id),
}
return json_comment

@staticmethod
def from_json(json_comment):
body = json_comment.get('body')
if body is None or body == '':
raise ValidationError('comment does not have a body')
return Comment(body=body)


db.event.listen(Comment.body, 'set', Comment.on_changed_body)
File renamed without changes.
107 changes: 107 additions & 0 deletions app/static/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
.profile-thumbnail {
position: absolute;
}
.profile-header {
min-height: 260px;
margin-left: 280px;
}
div.post-tabs {
margin-top: 16px;
}
ul.posts {
list-style-type: none;
padding: 0px;
margin: 16px 0px 0px 0px;
border-top: 1px solid #e0e0e0;
}
div.post-tabs ul.posts {
margin: 0px;
border-top: none;
}
ul.posts li.post {
padding: 8px;
border-bottom: 1px solid #e0e0e0;
}
ul.posts li.post:hover {
background-color: #f0f0f0;
}
div.post-date {
float: right;
}
div.post-author {
font-weight: bold;
}
div.post-thumbnail {
position: absolute;
}
div.post-content {
margin-left: 48px;
min-height: 48px;
}
div.post-footer {
text-align: right;
}
ul.comments {
list-style-type: none;
padding: 0px;
margin: 16px 0px 0px 0px;
}
ul.comments li.comment {
margin-left: 32px;
padding: 8px;
border-bottom: 1px solid #e0e0e0;
}
ul.comments li.comment:nth-child(1) {
border-top: 1px solid #e0e0e0;
}
ul.comments li.comment:hover {
background-color: #f0f0f0;
}
div.comment-date {
float: right;
}
div.comment-author {
font-weight: bold;
}
div.comment-thumbnail {
position: absolute;
}
div.comment-content {
margin-left: 48px;
min-height: 48px;
}
div.comment-form {
margin: 16px 0px 16px 32px;
}
div.pagination {
width: 100%;
text-align: right;
padding: 0px;
margin: 0px;
}
div.flask-pagedown-preview {
margin: 10px 0px 10px 0px;
border: 1px solid #e0e0e0;
padding: 4px;
}
div.flask-pagedown-preview h1 {
font-size: 140%;
}
div.flask-pagedown-preview h2 {
font-size: 130%;
}
div.flask-pagedown-preview h3 {
font-size: 120%;
}
.post-body h1 {
font-size: 140%;
}
.post-body h2 {
font-size: 130%;
}
.post-body h3 {
font-size: 120%;
}
.table.followers tr {
border-bottom: 1px solid #e0e0e0;
}
9 changes: 9 additions & 0 deletions app/templates/403.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% extends "base.html" %}

{% block title %}Flasky - Forbidden{% endblock %}

{% block page_content %}
<div class="page-header">
<h1>Forbidden</h1>
</div>
{% endblock %}
File renamed without changes.
File renamed without changes.
35 changes: 35 additions & 0 deletions app/templates/_comments.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<ul class="comments">
{% for comment in comments %}
<li class="comment">
<div class="comment-thumbnail">
<a href="{{ url_for('.user', username=comment.author.username) }}">
<img class="img-rounded profile-thumbnail" src="{{ comment.author.gravatar(size=40) }}">
</a>
</div>
<div class="comment-content">
<div class="comment-date">{{ moment(comment.timestamp).fromNow() }}</div>
<div class="comment-author"><a href="{{ url_for('.user', username=comment.author.username) }}">{{ comment.author.username }}</a></div>
<div class="comment-body">
{% if comment.disabled %}
<p><i>This comment has been disabled by a moderator.</i></p>
{% endif %}
{% if moderate or not comment.disabled %}
{% if comment.body_html %}
{{ comment.body_html | safe }}
{% else %}
{{ comment.body }}
{% endif %}
{% endif %}
</div>
{% if moderate %}
<br>
{% if comment.disabled %}
<a class="btn btn-default btn-xs" href="{{ url_for('.moderate_enable', id=comment.id, page=page) }}">Enable</a>
{% else %}
<a class="btn btn-danger btn-xs" href="{{ url_for('.moderate_disable', id=comment.id, page=page) }}">Disable</a>
{% endif %}
{% endif %}
</div>
</li>
{% endfor %}
</ul>
29 changes: 29 additions & 0 deletions app/templates/_macros.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{% macro pagination_widget(pagination, endpoint, fragment='') %}
<ul class="pagination">
<li{% if not pagination.has_prev %} class="disabled"{% endif %}>
<a href="{% if pagination.has_prev %}{{ url_for(endpoint, page=pagination.prev_num, **kwargs) }}{{ fragment }}{% else %}#{% endif %}">
&laquo;
</a>
</li>
{% for p in pagination.iter_pages() %}
{% if p %}
{% if p == pagination.page %}
<li class="active">
<a href="{{ url_for(endpoint, page = p, **kwargs) }}{{ fragment }}">{{ p }}</a>
</li>
{% else %}
<li>
<a href="{{ url_for(endpoint, page = p, **kwargs) }}{{ fragment }}">{{ p }}</a>
</li>
{% endif %}
{% else %}
<li class="disabled"><a href="#">&hellip;</a></li>
{% endif %}
{% endfor %}
<li{% if not pagination.has_next %} class="disabled"{% endif %}>
<a href="{% if pagination.has_next %}{{ url_for(endpoint, page=pagination.next_num, **kwargs) }}{{ fragment }}{% else %}#{% endif %}">
&raquo;
</a>
</li>
</ul>
{% endmacro %}
39 changes: 39 additions & 0 deletions app/templates/_posts.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<ul class="posts">
{% for post in posts %}
<li class="post">
<div class="post-thumbnail">
<a href="{{ url_for('.user', username=post.author.username) }}">
<img class="img-rounded profile-thumbnail" src="{{ post.author.gravatar(size=40) }}">
</a>
</div>
<div class="post-content">
<div class="post-date">{{ moment(post.timestamp).fromNow() }}</div>
<div class="post-author"><a href="{{ url_for('.user', username=post.author.username) }}">{{ post.author.username }}</a></div>
<div class="post-body">
{% if post.body_html %}
{{ post.body_html | safe }}
{% else %}
{{ post.body }}
{% endif %}
</div>
<div class="post-footer">
{% if current_user == post.author %}
<a href="{{ url_for('.edit', id=post.id) }}">
<span class="label label-primary">Edit</span>
</a>
{% elif current_user.is_administrator() %}
<a href="{{ url_for('.edit', id=post.id) }}">
<span class="label label-danger">Edit [Admin]</span>
</a>
{% endif %}
<a href="{{ url_for('.post', id=post.id) }}">
<span class="label label-default">Permalink</span>
</a>
<a href="{{ url_for('.post', id=post.id) }}#comments">
<span class="label label-primary">{{ post.comments.count() }} Comments</span>
</a>
</div>
</div>
</li>
{% endfor %}
</ul>
13 changes: 13 additions & 0 deletions app/templates/auth/change_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky - Change Email Address{% endblock %}

{% block page_content %}
<div class="page-header">
<h1>Change Your Email Address</h1>
</div>
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
{% endblock %}
13 changes: 13 additions & 0 deletions app/templates/auth/change_password.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky - Change Password{% endblock %}

{% block page_content %}
<div class="page-header">
<h1>Change Your Password</h1>
</div>
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
{% endblock %}
7 changes: 7 additions & 0 deletions app/templates/auth/email/change_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<p>Dear {{ user.username }},</p>
<p>To confirm your new email address <a href="{{ url_for('auth.change_email', token=token, _external=True) }}">click here</a>.</p>
<p>Alternatively, you can paste the following link in your browser's address bar:</p>
<p>{{ url_for('auth.change_email', token=token, _external=True) }}</p>
<p>Sincerely,</p>
<p>The Flasky Team</p>
<p><small>Note: replies to this email address are not monitored.</small></p>
11 changes: 11 additions & 0 deletions app/templates/auth/email/change_email.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Dear {{ user.username }},

To confirm your new email address click on the following link:

{{ url_for('auth.change_email', token=token, _external=True) }}

Sincerely,

The Flasky Team

Note: replies to this email address are not monitored.
8 changes: 8 additions & 0 deletions app/templates/auth/email/confirm.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<p>Dear {{ user.username }},</p>
<p>Welcome to <b>Flasky</b>!</p>
<p>To confirm your account please <a href="{{ url_for('auth.confirm', token=token, _external=True) }}">click here</a>.</p>
<p>Alternatively, you can paste the following link in your browser's address bar:</p>
<p>{{ url_for('auth.confirm', token=token, _external=True) }}</p>
<p>Sincerely,</p>
<p>The Flasky Team</p>
<p><small>Note: replies to this email address are not monitored.</small></p>
13 changes: 13 additions & 0 deletions app/templates/auth/email/confirm.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Dear {{ user.username }},

Welcome to Flasky!

To confirm your account please click on the following link:

{{ url_for('auth.confirm', token=token, _external=True) }}

Sincerely,

The Flasky Team

Note: replies to this email address are not monitored.
8 changes: 8 additions & 0 deletions app/templates/auth/email/reset_password.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<p>Dear {{ user.username }},</p>
<p>To reset your password <a href="{{ url_for('auth.password_reset', token=token, _external=True) }}">click here</a>.</p>
<p>Alternatively, you can paste the following link in your browser's address bar:</p>
<p>{{ url_for('auth.password_reset', token=token, _external=True) }}</p>
<p>If you have not requested a password reset simply ignore this message.</p>
<p>Sincerely,</p>
<p>The Flasky Team</p>
<p><small>Note: replies to this email address are not monitored.</small></p>
13 changes: 13 additions & 0 deletions app/templates/auth/email/reset_password.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Dear {{ user.username }},

To reset your password click on the following link:

{{ url_for('auth.password_reset', token=token, _external=True) }}

If you have not requested a password reset simply ignore this message.

Sincerely,

The Flasky Team

Note: replies to this email address are not monitored.
16 changes: 16 additions & 0 deletions app/templates/auth/login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky - Login{% endblock %}

{% block page_content %}
<div class="page-header">
<h1>Login</h1>
</div>
<div class="col-md-4">
{{ wtf.quick_form(form) }}
<br>
<p>Forgot your password? <a href="{{ url_for('auth.password_reset_request') }}">Click here to reset it</a>.</p>
<p>New user? <a href="{{ url_for('auth.register') }}">Click here to register</a>.</p>
</div>
{% endblock %}
13 changes: 13 additions & 0 deletions app/templates/auth/register.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky - Register{% endblock %}

{% block page_content %}
<div class="page-header">
<h1>Register</h1>
</div>
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
{% endblock %}
13 changes: 13 additions & 0 deletions app/templates/auth/reset_password.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky - Password Reset{% endblock %}

{% block page_content %}
<div class="page-header">
<h1>Reset Your Password</h1>
</div>
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
{% endblock %}
20 changes: 20 additions & 0 deletions app/templates/auth/unconfirmed.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{% extends "base.html" %}

{% block title %}Flasky - Confirm your account{% endblock %}

{% block page_content %}
<div class="page-header">
<h1>
Hello, {{ current_user.username }}!
</h1>
<h3>You have not confirmed your account yet.</h3>
<p>
Before you can access this site you need to confirm your account.
Check your inbox, you should have received an email with a confirmation link.
</p>
<p>
Need another confirmation email?
<a href="{{ url_for('auth.resend_confirmation') }}">Click here</a>
</p>
</div>
{% endblock %}
72 changes: 72 additions & 0 deletions app/templates/base.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{% extends "bootstrap/base.html" %}

{% block title %}Flasky{% endblock %}

{% block head %}
{{ super() }}
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles.css') }}">
{% endblock %}

{% block navbar %}
<div class="navbar navbar-inverse" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="{{ url_for('main.index') }}">Flasky</a>
</div>
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li><a href="{{ url_for('main.index') }}">Home</a></li>
{% if current_user.is_authenticated %}
<li><a href="{{ url_for('main.user', username=current_user.username) }}">Profile</a></li>
{% endif %}
</ul>
<ul class="nav navbar-nav navbar-right">
{% if current_user.can(Permission.MODERATE) %}
<li><a href="{{ url_for('main.moderate') }}">Moderate Comments</a></li>
{% endif %}
{% if current_user.is_authenticated %}
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
<img src="{{ current_user.gravatar(size=18) }}">
Account <b class="caret"></b>
</a>
<ul class="dropdown-menu">
<li><a href="{{ url_for('auth.change_password') }}">Change Password</a></li>
<li><a href="{{ url_for('auth.change_email_request') }}">Change Email</a></li>
<li><a href="{{ url_for('auth.logout') }}">Log Out</a></li>
</ul>
</li>
{% else %}
<li><a href="{{ url_for('auth.login') }}">Log In</a></li>
{% endif %}
</ul>
</div>
</div>
</div>
{% endblock %}

{% block content %}
<div class="container">
{% for message in get_flashed_messages() %}
<div class="alert alert-warning">
<button type="button" class="close" data-dismiss="alert">&times;</button>
{{ message }}
</div>
{% endfor %}

{% block page_content %}{% endblock %}
</div>
{% endblock %}

{% block scripts %}
{{ super() }}
{{ moment.include_moment() }}
{% endblock %}
18 changes: 18 additions & 0 deletions app/templates/edit_post.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky - Edit Post{% endblock %}

{% block page_content %}
<div class="page-header">
<h1>Edit Post</h1>
</div>
<div>
{{ wtf.quick_form(form) }}
</div>
{% endblock %}

{% block scripts %}
{{ super() }}
{{ pagedown.include_pagedown() }}
{% endblock %}
13 changes: 13 additions & 0 deletions app/templates/edit_profile.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky - Edit Profile{% endblock %}

{% block page_content %}
<div class="page-header">
<h1>Edit Your Profile</h1>
</div>
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
{% endblock %}
29 changes: 29 additions & 0 deletions app/templates/followers.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% import "_macros.html" as macros %}

{% block title %}Flasky - {{ title }} {{ user.username }}{% endblock %}

{% block page_content %}
<div class="page-header">
<h1>{{ title }} {{ user.username }}</h1>
</div>
<table class="table table-hover followers">
<thead><tr><th>User</th><th>Since</th></tr></thead>
{% for follow in follows %}
{% if follow.user != user %}
<tr>
<td>
<a href="{{ url_for('.user', username = follow.user.username) }}">
<img class="img-rounded" src="{{ follow.user.gravatar(size=32) }}">
{{ follow.user.username }}
</a>
</td>
<td>{{ moment(follow.timestamp).format('L') }}</td>
</tr>
{% endif %}
{% endfor %}
</table>
<div class="pagination">
{{ macros.pagination_widget(pagination, endpoint, username = user.username) }}
</div>
{% endblock %}
35 changes: 35 additions & 0 deletions app/templates/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% import "_macros.html" as macros %}

{% block title %}Flasky{% endblock %}

{% block page_content %}
<div class="page-header">
<h1>Hello, {% if current_user.is_authenticated %}{{ current_user.username }}{% else %}Stranger{% endif %}!</h1>
</div>
<div>
{% if current_user.can(Permission.WRITE) %}
{{ wtf.quick_form(form) }}
{% endif %}
</div>
<div class="post-tabs">
<ul class="nav nav-tabs">
<li{% if not show_followed %} class="active"{% endif %}><a href="{{ url_for('.show_all') }}">All</a></li>
{% if current_user.is_authenticated %}
<li{% if show_followed %} class="active"{% endif %}><a href="{{ url_for('.show_followed') }}">Followers</a></li>
{% endif %}
</ul>
{% include '_posts.html' %}
</div>
{% if pagination %}
<div class="pagination">
{{ macros.pagination_widget(pagination, '.index') }}
</div>
{% endif %}
{% endblock %}

{% block scripts %}
{{ super() }}
{{ pagedown.include_pagedown() }}
{% endblock %}
File renamed without changes.
File renamed without changes.
17 changes: 17 additions & 0 deletions app/templates/moderate.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% import "_macros.html" as macros %}

{% block title %}Flasky - Comment Moderation{% endblock %}

{% block page_content %}
<div class="page-header">
<h1>Comment Moderation</h1>
</div>
{% set moderate = True %}
{% include '_comments.html' %}
{% if pagination %}
<div class="pagination">
{{ macros.pagination_widget(pagination, '.moderate') }}
</div>
{% endif %}
{% endblock %}
21 changes: 21 additions & 0 deletions app/templates/post.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% import "_macros.html" as macros %}

{% block title %}Flasky - Post{% endblock %}

{% block page_content %}
{% include '_posts.html' %}
<h4 id="comments">Comments</h4>
{% if current_user.can(Permission.COMMENT) %}
<div class="comment-form">
{{ wtf.quick_form(form) }}
</div>
{% endif %}
{% include '_comments.html' %}
{% if pagination %}
<div class="pagination">
{{ macros.pagination_widget(pagination, '.post', fragment='#comments', id=posts[0].id) }}
</div>
{% endif %}
{% endblock %}
56 changes: 56 additions & 0 deletions app/templates/user.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% import "_macros.html" as macros %}

{% block title %}Flasky - {{ user.username }}{% endblock %}

{% block page_content %}
<div class="page-header">
<img class="img-rounded profile-thumbnail" src="{{ user.gravatar(size=256) }}">
<div class="profile-header">
<h1>{{ user.username }}</h1>
{% if user.name or user.location %}
<p>
{% if user.name %}{{ user.name }}<br>{% endif %}
{% if user.location %}
from <a href="http://maps.google.com/?q={{ user.location }}">{{ user.location }}</a><br>
{% endif %}
</p>
{% endif %}
{% if current_user.is_administrator() %}
<p><a href="mailto:{{ user.email }}">{{ user.email }}</a></p>
{% endif %}
{% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
<p>Member since {{ moment(user.member_since).format('L') }}. Last seen {{ moment(user.last_seen).fromNow() }}.</p>
<p>{{ user.posts.count() }} blog posts. {{ user.comments.count() }} comments.</p>
<p>
{% if current_user.can(Permission.FOLLOW) and user != current_user %}
{% if not current_user.is_following(user) %}
<a href="{{ url_for('.follow', username=user.username) }}" class="btn btn-primary">Follow</a>
{% else %}
<a href="{{ url_for('.unfollow', username=user.username) }}" class="btn btn-default">Unfollow</a>
{% endif %}
{% endif %}
<a href="{{ url_for('.followers', username=user.username) }}">Followers: <span class="badge">{{ user.followers.count() - 1 }}</span></a>
<a href="{{ url_for('.followed_by', username=user.username) }}">Following: <span class="badge">{{ user.followed.count() - 1 }}</span></a>
{% if current_user.is_authenticated and user != current_user and user.is_following(current_user) %}
| <span class="label label-default">Follows you</span>
{% endif %}
</p>
<p>
{% if user == current_user %}
<a class="btn btn-default" href="{{ url_for('.edit_profile') }}">Edit Profile</a>
{% endif %}
{% if current_user.is_administrator() %}
<a class="btn btn-danger" href="{{ url_for('.edit_profile_admin', id=user.id) }}">Edit Profile [Admin]</a>
{% endif %}
</p>
</div>
</div>
<h3>Posts by {{ user.username }}</h3>
{% include '_posts.html' %}
{% if pagination %}
<div class="pagination">
{{ macros.pagination_widget(pagination, '.user', username=user.username) }}
</div>
{% endif %}
{% endblock %}
13 changes: 13 additions & 0 deletions boot.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/bin/sh
source venv/bin/activate

while true; do
flask deploy
if [[ "$?" == "0" ]]; then
break
fi
echo Deploy command failed, retrying in 5 secs...
sleep 5
done

exec gunicorn -b :5000 --access-logfile - --error-logfile - flasky:app
124 changes: 124 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import os
basedir = os.path.abspath(os.path.dirname(__file__))


class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'hard to guess string'
MAIL_SERVER = os.environ.get('MAIL_SERVER', 'smtp.googlemail.com')
MAIL_PORT = int(os.environ.get('MAIL_PORT', '587'))
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in \
['true', 'on', '1']
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
FLASKY_MAIL_SUBJECT_PREFIX = '[Flasky]'
FLASKY_MAIL_SENDER = 'Flasky Admin <flasky@example.com>'
FLASKY_ADMIN = os.environ.get('FLASKY_ADMIN')
SSL_REDIRECT = False
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_RECORD_QUERIES = True
FLASKY_POSTS_PER_PAGE = 20
FLASKY_FOLLOWERS_PER_PAGE = 50
FLASKY_COMMENTS_PER_PAGE = 30
FLASKY_SLOW_DB_QUERY_TIME = 0.5

@staticmethod
def init_app(app):
pass


class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \
'sqlite:///' + os.path.join(basedir, 'data-dev.sqlite')


class TestingConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \
'sqlite://'
WTF_CSRF_ENABLED = False


class ProductionConfig(Config):
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
'sqlite:///' + os.path.join(basedir, 'data.sqlite')

@classmethod
def init_app(cls, app):
Config.init_app(app)

# email errors to the administrators
import logging
from logging.handlers import SMTPHandler
credentials = None
secure = None
if getattr(cls, 'MAIL_USERNAME', None) is not None:
credentials = (cls.MAIL_USERNAME, cls.MAIL_PASSWORD)
if getattr(cls, 'MAIL_USE_TLS', None):
secure = ()
mail_handler = SMTPHandler(
mailhost=(cls.MAIL_SERVER, cls.MAIL_PORT),
fromaddr=cls.FLASKY_MAIL_SENDER,
toaddrs=[cls.FLASKY_ADMIN],
subject=cls.FLASKY_MAIL_SUBJECT_PREFIX + ' Application Error',
credentials=credentials,
secure=secure)
mail_handler.setLevel(logging.ERROR)
app.logger.addHandler(mail_handler)


class HerokuConfig(ProductionConfig):
SSL_REDIRECT = True if os.environ.get('DYNO') else False

@classmethod
def init_app(cls, app):
ProductionConfig.init_app(app)

# handle reverse proxy server headers
from werkzeug.contrib.fixers import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app)

# log to stderr
import logging
from logging import StreamHandler
file_handler = StreamHandler()
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)


class DockerConfig(ProductionConfig):
@classmethod
def init_app(cls, app):
ProductionConfig.init_app(app)

# log to stderr
import logging
from logging import StreamHandler
file_handler = StreamHandler()
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)


class UnixConfig(ProductionConfig):
@classmethod
def init_app(cls, app):
ProductionConfig.init_app(app)

# log to syslog
import logging
from logging.handlers import SysLogHandler
syslog_handler = SysLogHandler()
syslog_handler.setLevel(logging.INFO)
app.logger.addHandler(syslog_handler)


config = {
'development': DevelopmentConfig,
'testing': TestingConfig,
'production': ProductionConfig,
'heroku': HerokuConfig,
'docker': DockerConfig,
'unix': UnixConfig,

'default': DevelopmentConfig
}
14 changes: 14 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
version: '3'
services:
flasky:
build: .
ports:
- "8000:5000"
env_file: .env
restart: always
links:
- mysql:dbserver
mysql:
image: "mysql/mysql-server:5.7"
env_file: .env-mysql
restart: always
78 changes: 78 additions & 0 deletions flasky.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import os
from dotenv import load_dotenv

dotenv_path = os.path.join(os.path.dirname(__file__), '.env')
if os.path.exists(dotenv_path):
load_dotenv(dotenv_path)

COV = None
if os.environ.get('FLASK_COVERAGE'):
import coverage
COV = coverage.coverage(branch=True, include='app/*')
COV.start()

import sys
import click
from flask_migrate import Migrate, upgrade
from app import create_app, db
from app.models import User, Follow, Role, Permission, Post, Comment

app = create_app(os.getenv('FLASK_CONFIG') or 'default')
migrate = Migrate(app, db)


@app.shell_context_processor
def make_shell_context():
return dict(db=db, User=User, Follow=Follow, Role=Role,
Permission=Permission, Post=Post, Comment=Comment)


@app.cli.command()
@click.option('--coverage/--no-coverage', default=False,
help='Run tests under code coverage.')
def test(coverage):
"""Run the unit tests."""
if coverage and not os.environ.get('FLASK_COVERAGE'):
import subprocess
os.environ['FLASK_COVERAGE'] = '1'
sys.exit(subprocess.call(sys.argv))

import unittest
tests = unittest.TestLoader().discover('tests')
unittest.TextTestRunner(verbosity=2).run(tests)
if COV:
COV.stop()
COV.save()
print('Coverage Summary:')
COV.report()
basedir = os.path.abspath(os.path.dirname(__file__))
covdir = os.path.join(basedir, 'tmp/coverage')
COV.html_report(directory=covdir)
print('HTML version: file://%s/index.html' % covdir)
COV.erase()


@app.cli.command()
@click.option('--length', default=25,
help='Number of functions to include in the profiler report.')
@click.option('--profile-dir', default=None,
help='Directory where profiler data files are saved.')
def profile(length, profile_dir):
"""Start the application under the code profiler."""
from werkzeug.contrib.profiler import ProfilerMiddleware
app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[length],
profile_dir=profile_dir)
app.run()


@app.cli.command()
def deploy():
"""Run deployment tasks."""
# migrate database to latest revision
upgrade()

# create or update user roles
Role.insert_roles()

# ensure all users are following themselves
User.add_self_follows()
109 changes: 0 additions & 109 deletions hello.py

This file was deleted.

26 changes: 26 additions & 0 deletions migrations/versions/190163627111_account_confirmation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""account confirmation
Revision ID: 190163627111
Revises: 456a945560f6
Create Date: 2013-12-29 02:58:45.577428
"""

# revision identifiers, used by Alembic.
revision = '190163627111'
down_revision = '456a945560f6'

from alembic import op
import sqlalchemy as sa


def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('confirmed', sa.Boolean(), nullable=True))
### end Alembic commands ###


def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'confirmed')
### end Alembic commands ###
26 changes: 26 additions & 0 deletions migrations/versions/198b0eebcf9_caching_of_avatar_hashes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""caching of avatar hashes
Revision ID: 198b0eebcf9
Revises: d66f086b258
Create Date: 2014-02-04 09:10:02.245503
"""

# revision identifiers, used by Alembic.
revision = '198b0eebcf9'
down_revision = 'd66f086b258'

from alembic import op
import sqlalchemy as sa


def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('avatar_hash', sa.String(length=32), nullable=True))
### end Alembic commands ###


def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'avatar_hash')
### end Alembic commands ###
35 changes: 35 additions & 0 deletions migrations/versions/1b966e7f4b9e_post_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""post model
Revision ID: 1b966e7f4b9e
Revises: 198b0eebcf9
Create Date: 2013-12-31 00:00:14.700591
"""

# revision identifiers, used by Alembic.
revision = '1b966e7f4b9e'
down_revision = '198b0eebcf9'

from alembic import op
import sqlalchemy as sa


def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('posts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('body', sa.Text(), nullable=True),
sa.Column('timestamp', sa.DateTime(), nullable=True),
sa.Column('author_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['author_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_posts_timestamp', 'posts', ['timestamp'], unique=False)
### end Alembic commands ###


def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_posts_timestamp', 'posts')
op.drop_table('posts')
### end Alembic commands ###
33 changes: 33 additions & 0 deletions migrations/versions/2356a38169ea_followers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""followers
Revision ID: 2356a38169ea
Revises: 288cd3dc5a8
Create Date: 2013-12-31 16:10:34.500006
"""

# revision identifiers, used by Alembic.
revision = '2356a38169ea'
down_revision = '288cd3dc5a8'

from alembic import op
import sqlalchemy as sa


def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('follows',
sa.Column('follower_id', sa.Integer(), nullable=False),
sa.Column('followed_id', sa.Integer(), nullable=False),
sa.Column('timestamp', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['followed_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['follower_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('follower_id', 'followed_id')
)
### end Alembic commands ###


def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_table('follows')
### end Alembic commands ###
26 changes: 26 additions & 0 deletions migrations/versions/288cd3dc5a8_rich_text_posts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""rich text posts
Revision ID: 288cd3dc5a8
Revises: 1b966e7f4b9e
Create Date: 2013-12-31 03:25:13.286503
"""

# revision identifiers, used by Alembic.
revision = '288cd3dc5a8'
down_revision = '1b966e7f4b9e'

from alembic import op
import sqlalchemy as sa


def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.add_column('posts', sa.Column('body_html', sa.Text(), nullable=True))
### end Alembic commands ###


def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_column('posts', 'body_html')
### end Alembic commands ###
30 changes: 30 additions & 0 deletions migrations/versions/456a945560f6_login_support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""login support
Revision ID: 456a945560f6
Revises: 38c4e85512a9
Create Date: 2013-12-29 00:18:35.795259
"""

# revision identifiers, used by Alembic.
revision = '456a945560f6'
down_revision = '38c4e85512a9'

from alembic import op
import sqlalchemy as sa


def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('email', sa.String(length=64), nullable=True))
op.add_column('users', sa.Column('password_hash', sa.String(length=128), nullable=True))
op.create_index('ix_users_email', 'users', ['email'], unique=True)
### end Alembic commands ###


def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_users_email', 'users')
op.drop_column('users', 'password_hash')
op.drop_column('users', 'email')
### end Alembic commands ###
39 changes: 39 additions & 0 deletions migrations/versions/51f5ccfba190_comments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""comments
Revision ID: 51f5ccfba190
Revises: 2356a38169ea
Create Date: 2014-01-01 12:08:43.287523
"""

# revision identifiers, used by Alembic.
revision = '51f5ccfba190'
down_revision = '2356a38169ea'

from alembic import op
import sqlalchemy as sa


def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('comments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('body', sa.Text(), nullable=True),
sa.Column('body_html', sa.Text(), nullable=True),
sa.Column('timestamp', sa.DateTime(), nullable=True),
sa.Column('disabled', sa.Boolean(), nullable=True),
sa.Column('author_id', sa.Integer(), nullable=True),
sa.Column('post_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['author_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['post_id'], ['posts.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_comments_timestamp', 'comments', ['timestamp'], unique=False)
### end Alembic commands ###


def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_comments_timestamp', 'comments')
op.drop_table('comments')
### end Alembic commands ###
30 changes: 30 additions & 0 deletions migrations/versions/56ed7d33de8d_user_roles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""user roles
Revision ID: 56ed7d33de8d
Revises: 190163627111
Create Date: 2013-12-29 22:19:54.212604
"""

# revision identifiers, used by Alembic.
revision = '56ed7d33de8d'
down_revision = '190163627111'

from alembic import op
import sqlalchemy as sa


def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.add_column('roles', sa.Column('default', sa.Boolean(), nullable=True))
op.add_column('roles', sa.Column('permissions', sa.Integer(), nullable=True))
op.create_index('ix_roles_default', 'roles', ['default'], unique=False)
### end Alembic commands ###


def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_roles_default', 'roles')
op.drop_column('roles', 'permissions')
op.drop_column('roles', 'default')
### end Alembic commands ###
34 changes: 34 additions & 0 deletions migrations/versions/d66f086b258_user_information.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""user information
Revision ID: d66f086b258
Revises: 56ed7d33de8d
Create Date: 2013-12-29 23:50:49.566954
"""

# revision identifiers, used by Alembic.
revision = 'd66f086b258'
down_revision = '56ed7d33de8d'

from alembic import op
import sqlalchemy as sa


def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('about_me', sa.Text(), nullable=True))
op.add_column('users', sa.Column('last_seen', sa.DateTime(), nullable=True))
op.add_column('users', sa.Column('location', sa.String(length=64), nullable=True))
op.add_column('users', sa.Column('member_since', sa.DateTime(), nullable=True))
op.add_column('users', sa.Column('name', sa.String(length=64), nullable=True))
### end Alembic commands ###


def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'name')
op.drop_column('users', 'member_since')
op.drop_column('users', 'location')
op.drop_column('users', 'last_seen')
op.drop_column('users', 'about_me')
### end Alembic commands ###
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# this requirements file is used by Heroku
# requirements for other configurations are located in the requirements subdirectory
-r requirements/heroku.txt
30 changes: 30 additions & 0 deletions requirements/common.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
alembic==0.9.3
bleach==2.0.0
blinker==1.4
click==6.7
dominate==2.3.1
Flask==0.12.2
Flask-Bootstrap==3.3.7.1
Flask-HTTPAuth==3.2.3
Flask-Login==0.4.0
Flask-Mail==0.9.1
Flask-Migrate==2.0.4
Flask-Moment==0.5.1
Flask-PageDown==0.2.2
Flask-SQLAlchemy==2.2
Flask-WTF==0.14.2
html5lib==0.999999999
itsdangerous==0.24
Jinja2==2.9.6
Mako==1.0.7
Markdown==2.6.8
MarkupSafe==1.0
python-dateutil==2.6.1
python-dotenv==0.6.5
python-editor==1.0.3
six==1.10.0
SQLAlchemy==1.1.11
visitor==0.1.3
webencodings==0.5.1
Werkzeug==0.12.2
WTForms==2.1
11 changes: 11 additions & 0 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-r common.txt
certifi==2017.7.27.1
chardet==3.0.4
coverage==4.4.1
faker==0.7.18
httpie==0.9.9
idna==2.5
Pygments==2.2.0
requests==2.18.2
selenium==3.4.3
urllib3==1.22
3 changes: 3 additions & 0 deletions requirements/docker.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-r common.txt
gunicorn==19.7.1
pymysql==0.7.11
4 changes: 4 additions & 0 deletions requirements/heroku.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-r prod.txt
Flask-SSLify==0.1.5
gunicorn==19.7.1
psycopg2==2.7.3
1 change: 1 addition & 0 deletions requirements/prod.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-r common.txt
48 changes: 0 additions & 48 deletions templates/base.html

This file was deleted.

16 changes: 0 additions & 16 deletions templates/index.html

This file was deleted.

Empty file added tests/__init__.py
Empty file.
264 changes: 264 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
import unittest
import json
import re
from base64 import b64encode
from app import create_app, db
from app.models import User, Role, Post, Comment


class APITestCase(unittest.TestCase):
def setUp(self):
self.app = create_app('testing')
self.app_context = self.app.app_context()
self.app_context.push()
db.create_all()
Role.insert_roles()
self.client = self.app.test_client()

def tearDown(self):
db.session.remove()
db.drop_all()
self.app_context.pop()

def get_api_headers(self, username, password):
return {
'Authorization': 'Basic ' + b64encode(
(username + ':' + password).encode('utf-8')).decode('utf-8'),
'Accept': 'application/json',
'Content-Type': 'application/json'
}

def test_404(self):
response = self.client.get(
'/wrong/url',
headers=self.get_api_headers('email', 'password'))
self.assertEqual(response.status_code, 404)
json_response = json.loads(response.get_data(as_text=True))
self.assertEqual(json_response['error'], 'not found')

def test_no_auth(self):
response = self.client.get('/api/v1/posts/',
content_type='application/json')
self.assertEqual(response.status_code, 401)

def test_bad_auth(self):
# add a user
r = Role.query.filter_by(name='User').first()
self.assertIsNotNone(r)
u = User(email='john@example.com', password='cat', confirmed=True,
role=r)
db.session.add(u)
db.session.commit()

# authenticate with bad password
response = self.client.get(
'/api/v1/posts/',
headers=self.get_api_headers('john@example.com', 'dog'))
self.assertEqual(response.status_code, 401)

def test_token_auth(self):
# add a user
r = Role.query.filter_by(name='User').first()
self.assertIsNotNone(r)
u = User(email='john@example.com', password='cat', confirmed=True,
role=r)
db.session.add(u)
db.session.commit()

# issue a request with a bad token
response = self.client.get(
'/api/v1/posts/',
headers=self.get_api_headers('bad-token', ''))
self.assertEqual(response.status_code, 401)

# get a token
response = self.client.post(
'/api/v1/tokens/',
headers=self.get_api_headers('john@example.com', 'cat'))
self.assertEqual(response.status_code, 200)
json_response = json.loads(response.get_data(as_text=True))
self.assertIsNotNone(json_response.get('token'))
token = json_response['token']

# issue a request with the token
response = self.client.get(
'/api/v1/posts/',
headers=self.get_api_headers(token, ''))
self.assertEqual(response.status_code, 200)

def test_anonymous(self):
response = self.client.get(
'/api/v1/posts/',
headers=self.get_api_headers('', ''))
self.assertEqual(response.status_code, 401)

def test_unconfirmed_account(self):
# add an unconfirmed user
r = Role.query.filter_by(name='User').first()
self.assertIsNotNone(r)
u = User(email='john@example.com', password='cat', confirmed=False,
role=r)
db.session.add(u)
db.session.commit()

# get list of posts with the unconfirmed account
response = self.client.get(
'/api/v1/posts/',
headers=self.get_api_headers('john@example.com', 'cat'))
self.assertEqual(response.status_code, 403)

def test_posts(self):
# add a user
r = Role.query.filter_by(name='User').first()
self.assertIsNotNone(r)
u = User(email='john@example.com', password='cat', confirmed=True,
role=r)
db.session.add(u)
db.session.commit()

# write an empty post
response = self.client.post(
'/api/v1/posts/',
headers=self.get_api_headers('john@example.com', 'cat'),
data=json.dumps({'body': ''}))
self.assertEqual(response.status_code, 400)

# write a post
response = self.client.post(
'/api/v1/posts/',
headers=self.get_api_headers('john@example.com', 'cat'),
data=json.dumps({'body': 'body of the *blog* post'}))
self.assertEqual(response.status_code, 201)
url = response.headers.get('Location')
self.assertIsNotNone(url)

# get the new post
response = self.client.get(
url,
headers=self.get_api_headers('john@example.com', 'cat'))
self.assertEqual(response.status_code, 200)
json_response = json.loads(response.get_data(as_text=True))
self.assertEqual('http://localhost' + json_response['url'], url)
self.assertEqual(json_response['body'], 'body of the *blog* post')
self.assertEqual(json_response['body_html'],
'<p>body of the <em>blog</em> post</p>')
json_post = json_response

# get the post from the user
response = self.client.get(
'/api/v1/users/{}/posts/'.format(u.id),
headers=self.get_api_headers('john@example.com', 'cat'))
self.assertEqual(response.status_code, 200)
json_response = json.loads(response.get_data(as_text=True))
self.assertIsNotNone(json_response.get('posts'))
self.assertEqual(json_response.get('count', 0), 1)
self.assertEqual(json_response['posts'][0], json_post)

# get the post from the user as a follower
response = self.client.get(
'/api/v1/users/{}/timeline/'.format(u.id),
headers=self.get_api_headers('john@example.com', 'cat'))
self.assertEqual(response.status_code, 200)
json_response = json.loads(response.get_data(as_text=True))
self.assertIsNotNone(json_response.get('posts'))
self.assertEqual(json_response.get('count', 0), 1)
self.assertEqual(json_response['posts'][0], json_post)

# edit post
response = self.client.put(
url,
headers=self.get_api_headers('john@example.com', 'cat'),
data=json.dumps({'body': 'updated body'}))
self.assertEqual(response.status_code, 200)
json_response = json.loads(response.get_data(as_text=True))
self.assertEqual('http://localhost' + json_response['url'], url)
self.assertEqual(json_response['body'], 'updated body')
self.assertEqual(json_response['body_html'], '<p>updated body</p>')

def test_users(self):
# add two users
r = Role.query.filter_by(name='User').first()
self.assertIsNotNone(r)
u1 = User(email='john@example.com', username='john',
password='cat', confirmed=True, role=r)
u2 = User(email='susan@example.com', username='susan',
password='dog', confirmed=True, role=r)
db.session.add_all([u1, u2])
db.session.commit()

# get users
response = self.client.get(
'/api/v1/users/{}'.format(u1.id),
headers=self.get_api_headers('susan@example.com', 'dog'))
self.assertEqual(response.status_code, 200)
json_response = json.loads(response.get_data(as_text=True))
self.assertEqual(json_response['username'], 'john')
response = self.client.get(
'/api/v1/users/{}'.format(u2.id),
headers=self.get_api_headers('susan@example.com', 'dog'))
self.assertEqual(response.status_code, 200)
json_response = json.loads(response.get_data(as_text=True))
self.assertEqual(json_response['username'], 'susan')

def test_comments(self):
# add two users
r = Role.query.filter_by(name='User').first()
self.assertIsNotNone(r)
u1 = User(email='john@example.com', username='john',
password='cat', confirmed=True, role=r)
u2 = User(email='susan@example.com', username='susan',
password='dog', confirmed=True, role=r)
db.session.add_all([u1, u2])
db.session.commit()

# add a post
post = Post(body='body of the post', author=u1)
db.session.add(post)
db.session.commit()

# write a comment
response = self.client.post(
'/api/v1/posts/{}/comments/'.format(post.id),
headers=self.get_api_headers('susan@example.com', 'dog'),
data=json.dumps({'body': 'Good [post](http://example.com)!'}))
self.assertEqual(response.status_code, 201)
json_response = json.loads(response.get_data(as_text=True))
url = response.headers.get('Location')
self.assertIsNotNone(url)
self.assertEqual(json_response['body'],
'Good [post](http://example.com)!')
self.assertEqual(
re.sub('<.*?>', '', json_response['body_html']), 'Good post!')

# get the new comment
response = self.client.get(
url,
headers=self.get_api_headers('john@example.com', 'cat'))
self.assertEqual(response.status_code, 200)
json_response = json.loads(response.get_data(as_text=True))
self.assertEqual('http://localhost' + json_response['url'], url)
self.assertEqual(json_response['body'],
'Good [post](http://example.com)!')

# add another comment
comment = Comment(body='Thank you!', author=u1, post=post)
db.session.add(comment)
db.session.commit()

# get the two comments from the post
response = self.client.get(
'/api/v1/posts/{}/comments/'.format(post.id),
headers=self.get_api_headers('susan@example.com', 'dog'))
self.assertEqual(response.status_code, 200)
json_response = json.loads(response.get_data(as_text=True))
self.assertIsNotNone(json_response.get('comments'))
self.assertEqual(json_response.get('count', 0), 2)

# get all the comments
response = self.client.get(
'/api/v1/posts/{}/comments/'.format(post.id),
headers=self.get_api_headers('susan@example.com', 'dog'))
self.assertEqual(response.status_code, 200)
json_response = json.loads(response.get_data(as_text=True))
self.assertIsNotNone(json_response.get('comments'))
self.assertEqual(json_response.get('count', 0), 2)
22 changes: 22 additions & 0 deletions tests/test_basics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import unittest
from flask import current_app
from app import create_app, db


class BasicsTestCase(unittest.TestCase):
def setUp(self):
self.app = create_app('testing')
self.app_context = self.app.app_context()
self.app_context.push()
db.create_all()

def tearDown(self):
db.session.remove()
db.drop_all()
self.app_context.pop()

def test_app_exists(self):
self.assertFalse(current_app is None)

def test_app_is_testing(self):
self.assertTrue(current_app.config['TESTING'])
62 changes: 62 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import re
import unittest
from app import create_app, db
from app.models import User, Role

class FlaskClientTestCase(unittest.TestCase):
def setUp(self):
self.app = create_app('testing')
self.app_context = self.app.app_context()
self.app_context.push()
db.create_all()
Role.insert_roles()
self.client = self.app.test_client(use_cookies=True)

def tearDown(self):
db.session.remove()
db.drop_all()
self.app_context.pop()

def test_home_page(self):
response = self.client.get('/')
self.assertEqual(response.status_code, 200)
self.assertTrue('Stranger' in response.get_data(as_text=True))

def test_register_and_login(self):
# register a new account
response = self.client.post('/auth/register', data={
'email': 'john@example.com',
'username': 'john',
'password': 'cat',
'password2': 'cat'
})
self.assertEqual(response.status_code, 302)

# login with the new account
response = self.client.post('/auth/login', data={
'email': 'john@example.com',
'password': 'cat'
}, follow_redirects=True)
self.assertEqual(response.status_code, 200)
self.assertTrue(re.search('Hello,\s+john!',
response.get_data(as_text=True)))
self.assertTrue(
'You have not confirmed your account yet' in response.get_data(
as_text=True))

# send a confirmation token
user = User.query.filter_by(email='john@example.com').first()
token = user.generate_confirmation_token()
response = self.client.get('/auth/confirm/{}'.format(token),
follow_redirects=True)
user.confirm(token)
self.assertEqual(response.status_code, 200)
self.assertTrue(
'You have confirmed your account' in response.get_data(
as_text=True))

# log out
response = self.client.get('/auth/logout', follow_redirects=True)
self.assertEqual(response.status_code, 200)
self.assertTrue('You have been logged out' in response.get_data(
as_text=True))
98 changes: 98 additions & 0 deletions tests/test_selenium.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import re
import threading
import time
import unittest
from selenium import webdriver
from app import create_app, db, fake
from app.models import Role, User, Post


class SeleniumTestCase(unittest.TestCase):
client = None

@classmethod
def setUpClass(cls):
# start Chrome
options = webdriver.ChromeOptions()
options.add_argument('headless')
try:
cls.client = webdriver.Chrome(chrome_options=options)
except:
pass

# skip these tests if the browser could not be started
if cls.client:
# create the application
cls.app = create_app('testing')
cls.app_context = cls.app.app_context()
cls.app_context.push()

# suppress logging to keep unittest output clean
import logging
logger = logging.getLogger('werkzeug')
logger.setLevel("ERROR")

# create the database and populate with some fake data
db.create_all()
Role.insert_roles()
fake.users(10)
fake.posts(10)

# add an administrator user
admin_role = Role.query.filter_by(name='Administrator').first()
admin = User(email='john@example.com',
username='john', password='cat',
role=admin_role, confirmed=True)
db.session.add(admin)
db.session.commit()

# start the Flask server in a thread
cls.server_thread = threading.Thread(target=cls.app.run,
kwargs={'debug': False})
cls.server_thread.start()

# give the server a second to ensure it is up
time.sleep(1)

@classmethod
def tearDownClass(cls):
if cls.client:
# stop the flask server and the browser
cls.client.get('http://localhost:5000/shutdown')
cls.client.quit()
cls.server_thread.join()

# destroy database
db.drop_all()
db.session.remove()

# remove application context
cls.app_context.pop()

def setUp(self):
if not self.client:
self.skipTest('Web browser not available')

def tearDown(self):
pass

def test_admin_home_page(self):
# navigate to home page
self.client.get('http://localhost:5000/')
self.assertTrue(re.search('Hello,\s+Stranger!',
self.client.page_source))

# navigate to login page
self.client.find_element_by_link_text('Log In').click()
self.assertIn('<h1>Login</h1>', self.client.page_source)

# login
self.client.find_element_by_name('email').\
send_keys('john@example.com')
self.client.find_element_by_name('password').send_keys('cat')
self.client.find_element_by_name('submit').click()
self.assertTrue(re.search('Hello,\s+john!', self.client.page_source))

# navigate to the user's profile page
self.client.find_element_by_link_text('Profile').click()
self.assertIn('<h1>john</h1>', self.client.page_source)
219 changes: 219 additions & 0 deletions tests/test_user_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import unittest
import time
from datetime import datetime
from app import create_app, db
from app.models import User, AnonymousUser, Role, Permission, Follow


class UserModelTestCase(unittest.TestCase):
def setUp(self):
self.app = create_app('testing')
self.app_context = self.app.app_context()
self.app_context.push()
db.create_all()
Role.insert_roles()

def tearDown(self):
db.session.remove()
db.drop_all()
self.app_context.pop()

def test_password_setter(self):
u = User(password='cat')
self.assertTrue(u.password_hash is not None)

def test_no_password_getter(self):
u = User(password='cat')
with self.assertRaises(AttributeError):
u.password

def test_password_verification(self):
u = User(password='cat')
self.assertTrue(u.verify_password('cat'))
self.assertFalse(u.verify_password('dog'))

def test_password_salts_are_random(self):
u = User(password='cat')
u2 = User(password='cat')
self.assertTrue(u.password_hash != u2.password_hash)

def test_valid_confirmation_token(self):
u = User(password='cat')
db.session.add(u)
db.session.commit()
token = u.generate_confirmation_token()
self.assertTrue(u.confirm(token))

def test_invalid_confirmation_token(self):
u1 = User(password='cat')
u2 = User(password='dog')
db.session.add(u1)
db.session.add(u2)
db.session.commit()
token = u1.generate_confirmation_token()
self.assertFalse(u2.confirm(token))

def test_expired_confirmation_token(self):
u = User(password='cat')
db.session.add(u)
db.session.commit()
token = u.generate_confirmation_token(1)
time.sleep(2)
self.assertFalse(u.confirm(token))

def test_valid_reset_token(self):
u = User(password='cat')
db.session.add(u)
db.session.commit()
token = u.generate_reset_token()
self.assertTrue(User.reset_password(token, 'dog'))
self.assertTrue(u.verify_password('dog'))

def test_invalid_reset_token(self):
u = User(password='cat')
db.session.add(u)
db.session.commit()
token = u.generate_reset_token()
self.assertFalse(User.reset_password(token + 'a', 'horse'))
self.assertTrue(u.verify_password('cat'))

def test_valid_email_change_token(self):
u = User(email='john@example.com', password='cat')
db.session.add(u)
db.session.commit()
token = u.generate_email_change_token('susan@example.org')
self.assertTrue(u.change_email(token))
self.assertTrue(u.email == 'susan@example.org')

def test_invalid_email_change_token(self):
u1 = User(email='john@example.com', password='cat')
u2 = User(email='susan@example.org', password='dog')
db.session.add(u1)
db.session.add(u2)
db.session.commit()
token = u1.generate_email_change_token('david@example.net')
self.assertFalse(u2.change_email(token))
self.assertTrue(u2.email == 'susan@example.org')

def test_duplicate_email_change_token(self):
u1 = User(email='john@example.com', password='cat')
u2 = User(email='susan@example.org', password='dog')
db.session.add(u1)
db.session.add(u2)
db.session.commit()
token = u2.generate_email_change_token('john@example.com')
self.assertFalse(u2.change_email(token))
self.assertTrue(u2.email == 'susan@example.org')

def test_user_role(self):
u = User(email='john@example.com', password='cat')
self.assertTrue(u.can(Permission.FOLLOW))
self.assertTrue(u.can(Permission.COMMENT))
self.assertTrue(u.can(Permission.WRITE))
self.assertFalse(u.can(Permission.MODERATE))
self.assertFalse(u.can(Permission.ADMIN))

def test_moderator_role(self):
r = Role.query.filter_by(name='Moderator').first()
u = User(email='john@example.com', password='cat', role=r)
self.assertTrue(u.can(Permission.FOLLOW))
self.assertTrue(u.can(Permission.COMMENT))
self.assertTrue(u.can(Permission.WRITE))
self.assertTrue(u.can(Permission.MODERATE))
self.assertFalse(u.can(Permission.ADMIN))

def test_administrator_role(self):
r = Role.query.filter_by(name='Administrator').first()
u = User(email='john@example.com', password='cat', role=r)
self.assertTrue(u.can(Permission.FOLLOW))
self.assertTrue(u.can(Permission.COMMENT))
self.assertTrue(u.can(Permission.WRITE))
self.assertTrue(u.can(Permission.MODERATE))
self.assertTrue(u.can(Permission.ADMIN))

def test_anonymous_user(self):
u = AnonymousUser()
self.assertFalse(u.can(Permission.FOLLOW))
self.assertFalse(u.can(Permission.COMMENT))
self.assertFalse(u.can(Permission.WRITE))
self.assertFalse(u.can(Permission.MODERATE))
self.assertFalse(u.can(Permission.ADMIN))

def test_timestamps(self):
u = User(password='cat')
db.session.add(u)
db.session.commit()
self.assertTrue(
(datetime.utcnow() - u.member_since).total_seconds() < 3)
self.assertTrue(
(datetime.utcnow() - u.last_seen).total_seconds() < 3)

def test_ping(self):
u = User(password='cat')
db.session.add(u)
db.session.commit()
time.sleep(2)
last_seen_before = u.last_seen
u.ping()
self.assertTrue(u.last_seen > last_seen_before)

def test_gravatar(self):
u = User(email='john@example.com', password='cat')
with self.app.test_request_context('/'):
gravatar = u.gravatar()
gravatar_256 = u.gravatar(size=256)
gravatar_pg = u.gravatar(rating='pg')
gravatar_retro = u.gravatar(default='retro')
self.assertTrue('https://secure.gravatar.com/avatar/' +
'd4c74594d841139328695756648b6bd6'in gravatar)
self.assertTrue('s=256' in gravatar_256)
self.assertTrue('r=pg' in gravatar_pg)
self.assertTrue('d=retro' in gravatar_retro)

def test_follows(self):
u1 = User(email='john@example.com', password='cat')
u2 = User(email='susan@example.org', password='dog')
db.session.add(u1)
db.session.add(u2)
db.session.commit()
self.assertFalse(u1.is_following(u2))
self.assertFalse(u1.is_followed_by(u2))
timestamp_before = datetime.utcnow()
u1.follow(u2)
db.session.add(u1)
db.session.commit()
timestamp_after = datetime.utcnow()
self.assertTrue(u1.is_following(u2))
self.assertFalse(u1.is_followed_by(u2))
self.assertTrue(u2.is_followed_by(u1))
self.assertTrue(u1.followed.count() == 2)
self.assertTrue(u2.followers.count() == 2)
f = u1.followed.all()[-1]
self.assertTrue(f.followed == u2)
self.assertTrue(timestamp_before <= f.timestamp <= timestamp_after)
f = u2.followers.all()[-1]
self.assertTrue(f.follower == u1)
u1.unfollow(u2)
db.session.add(u1)
db.session.commit()
self.assertTrue(u1.followed.count() == 1)
self.assertTrue(u2.followers.count() == 1)
self.assertTrue(Follow.query.count() == 2)
u2.follow(u1)
db.session.add(u1)
db.session.add(u2)
db.session.commit()
db.session.delete(u2)
db.session.commit()
self.assertTrue(Follow.query.count() == 1)

def test_to_json(self):
u = User(email='john@example.com', password='cat')
db.session.add(u)
db.session.commit()
with self.app.test_request_context('/'):
json_user = u.to_json()
expected_keys = ['url', 'username', 'member_since', 'last_seen',
'posts_url', 'followed_posts_url', 'post_count']
self.assertEqual(sorted(json_user.keys()), sorted(expected_keys))
self.assertEqual('/api/v1/users/' + str(u.id), json_user['url'])