diff --git a/.gitignore b/.gitignore index 1a86cb06b..37ce1aa50 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,3 @@ nosetests.xml # Virtual environment venv - -# Environment files -.env -.env-mysql diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 88d94d100..000000000 --- a/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -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"] diff --git a/Procfile b/Procfile deleted file mode 100644 index 541c902b3..000000000 --- a/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: gunicorn flasky:app diff --git a/README.md b/README.md index 8f194abce..2088fa41d 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,7 @@ Flasky ====== -This repository contains the source code examples for the second edition of my O'Reilly book [Flask Web Development](http://www.flaskbook.com). +This repository contains the source code examples for my O'Reilly book [Flask Web Development](http://www.flaskbook.com). The commits and tags in this repository were carefully created to match the sequence in which concepts are presented in the book. Please read the section titled "How to Work with the Example Code" in the book's preface for instructions. -For Readers of the First Edition of the Book --------------------------------------------- - -The code examples for the first edition of the book were moved to a different repository: [https://github.com/miguelgrinberg/flasky-first-edition](https://github.com/miguelgrinberg/flasky-first-edition). diff --git a/app/__init__.py b/app/__init__.py index a0d325a37..22eb202df 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,10 +1,10 @@ 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 flask.ext.bootstrap import Bootstrap +from flask.ext.mail import Mail +from flask.ext.moment import Moment +from flask.ext.sqlalchemy import SQLAlchemy +from flask.ext.login import LoginManager +from flask.ext.pagedown import PageDown from config import config bootstrap = Bootstrap() @@ -14,6 +14,7 @@ pagedown = PageDown() login_manager = LoginManager() +login_manager.session_protection = 'strong' login_manager.login_view = 'auth.login' @@ -29,8 +30,8 @@ def create_app(config_name): login_manager.init_app(app) pagedown.init_app(app) - if app.config['SSL_REDIRECT']: - from flask_sslify import SSLify + if not app.debug and not app.testing and not app.config['SSL_DISABLE']: + from flask.ext.sslify import SSLify sslify = SSLify(app) from .main import main as main_blueprint @@ -39,7 +40,7 @@ def create_app(config_name): 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') + from .api_1_0 import api as api_1_0_blueprint + app.register_blueprint(api_1_0_blueprint, url_prefix='/api/v1.0') return app diff --git a/app/api/__init__.py b/app/api_1_0/__init__.py similarity index 100% rename from app/api/__init__.py rename to app/api_1_0/__init__.py diff --git a/app/api/authentication.py b/app/api_1_0/authentication.py similarity index 81% rename from app/api/authentication.py rename to app/api_1_0/authentication.py index a9c66f4e9..63f90cbba 100644 --- a/app/api/authentication.py +++ b/app/api_1_0/authentication.py @@ -1,6 +1,6 @@ from flask import g, jsonify -from flask_httpauth import HTTPBasicAuth -from ..models import User +from flask.ext.httpauth import HTTPBasicAuth +from ..models import User, AnonymousUser from . import api from .errors import unauthorized, forbidden @@ -10,12 +10,13 @@ @auth.verify_password def verify_password(email_or_token, password): if email_or_token == '': - return False + g.current_user = AnonymousUser() + return True 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.lower()).first() + user = User.query.filter_by(email=email_or_token).first() if not user: return False g.current_user = user @@ -36,7 +37,7 @@ def before_request(): return forbidden('Unconfirmed account') -@api.route('/tokens/', methods=['POST']) +@api.route('/token') def get_token(): if g.current_user.is_anonymous or g.token_used: return unauthorized('Invalid credentials') diff --git a/app/api/comments.py b/app/api_1_0/comments.py similarity index 70% rename from app/api/comments.py rename to app/api_1_0/comments.py index 1d5f18e2d..3ca8544e3 100644 --- a/app/api/comments.py +++ b/app/api_1_0/comments.py @@ -9,17 +9,17 @@ def get_comments(): page = request.args.get('page', 1, type=int) pagination = Comment.query.order_by(Comment.timestamp.desc()).paginate( - page=page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'], + 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) + prev = url_for('api.get_comments', page=page-1, _external=True) next = None if pagination.has_next: - next = url_for('api.get_comments', page=page+1) + next = url_for('api.get_comments', page=page+1, _external=True) return jsonify({ - 'comments': [comment.to_json() for comment in comments], + 'posts': [comment.to_json() for comment in comments], 'prev': prev, 'next': next, 'count': pagination.total @@ -37,17 +37,17 @@ 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=page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'], + 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) + prev = url_for('api.get_comments', page=page-1, _external=True) next = None if pagination.has_next: - next = url_for('api.get_post_comments', id=id, page=page+1) + next = url_for('api.get_comments', page=page+1, _external=True) return jsonify({ - 'comments': [comment.to_json() for comment in comments], + 'posts': [comment.to_json() for comment in comments], 'prev': prev, 'next': next, 'count': pagination.total @@ -64,4 +64,5 @@ def new_post_comment(id): db.session.add(comment) db.session.commit() return jsonify(comment.to_json()), 201, \ - {'Location': url_for('api.get_comment', id=comment.id)} + {'Location': url_for('api.get_comment', id=comment.id, + _external=True)} diff --git a/app/api/decorators.py b/app/api_1_0/decorators.py similarity index 100% rename from app/api/decorators.py rename to app/api_1_0/decorators.py diff --git a/app/api/errors.py b/app/api_1_0/errors.py similarity index 100% rename from app/api/errors.py rename to app/api_1_0/errors.py diff --git a/app/api/posts.py b/app/api_1_0/posts.py similarity index 71% rename from app/api/posts.py rename to app/api_1_0/posts.py index 79d0f7e23..a7fecac08 100644 --- a/app/api/posts.py +++ b/app/api_1_0/posts.py @@ -1,4 +1,4 @@ -from flask import jsonify, request, g, url_for, current_app +from flask import jsonify, request, g, abort, url_for, current_app from .. import db from ..models import Post, Permission from . import api @@ -10,15 +10,15 @@ def get_posts(): page = request.args.get('page', 1, type=int) pagination = Post.query.paginate( - page=page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'], + 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) + prev = url_for('api.get_posts', page=page-1, _external=True) next = None if pagination.has_next: - next = url_for('api.get_posts', page=page+1) + next = url_for('api.get_posts', page=page+1, _external=True) return jsonify({ 'posts': [post.to_json() for post in posts], 'prev': prev, @@ -34,24 +34,23 @@ def get_post(id): @api.route('/posts/', methods=['POST']) -@permission_required(Permission.WRITE) +@permission_required(Permission.WRITE_ARTICLES) 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)} + {'Location': url_for('api.get_post', id=post.id, _external=True)} @api.route('/posts/', methods=['PUT']) -@permission_required(Permission.WRITE) +@permission_required(Permission.WRITE_ARTICLES) 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): + not g.current_user.can(Permission.ADMINISTER): return forbidden('Insufficient permissions') post.body = request.json.get('body', post.body) db.session.add(post) - db.session.commit() return jsonify(post.to_json()) diff --git a/app/api/users.py b/app/api_1_0/users.py similarity index 75% rename from app/api/users.py rename to app/api_1_0/users.py index 80c9481ea..e4e6a776d 100644 --- a/app/api/users.py +++ b/app/api_1_0/users.py @@ -14,15 +14,15 @@ 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=page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'], + 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) + prev = url_for('api.get_posts', page=page-1, _external=True) next = None if pagination.has_next: - next = url_for('api.get_user_posts', id=id, page=page+1) + next = url_for('api.get_posts', page=page+1, _external=True) return jsonify({ 'posts': [post.to_json() for post in posts], 'prev': prev, @@ -36,15 +36,15 @@ 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=page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'], + 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) + prev = url_for('api.get_posts', page=page-1, _external=True) next = None if pagination.has_next: - next = url_for('api.get_user_followed_posts', id=id, page=page+1) + next = url_for('api.get_posts', page=page+1, _external=True) return jsonify({ 'posts': [post.to_json() for post in posts], 'prev': prev, diff --git a/app/auth/forms.py b/app/auth/forms.py index d59738dc5..e8853b651 100644 --- a/app/auth/forms.py +++ b/app/auth/forms.py @@ -1,33 +1,32 @@ -from flask_wtf import FlaskForm +from flask.ext.wtf import Form from wtforms import StringField, PasswordField, BooleanField, SubmitField -from wtforms.validators import DataRequired, Length, Email, Regexp, EqualTo +from wtforms.validators import Required, Length, Email, Regexp, EqualTo from wtforms import ValidationError from ..models import User -class LoginForm(FlaskForm): - email = StringField('Email', validators=[DataRequired(), Length(1, 64), +class LoginForm(Form): + email = StringField('Email', validators=[Required(), Length(1, 64), Email()]) - password = PasswordField('Password', validators=[DataRequired()]) + password = PasswordField('Password', validators=[Required()]) remember_me = BooleanField('Keep me logged in') submit = SubmitField('Log In') -class RegistrationForm(FlaskForm): - email = StringField('Email', validators=[DataRequired(), Length(1, 64), - Email()]) +class RegistrationForm(Form): + email = StringField('Email', validators=[Required(), 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')]) + Required(), 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()]) + Required(), EqualTo('password2', message='Passwords must match.')]) + password2 = PasswordField('Confirm password', validators=[Required()]) submit = SubmitField('Register') def validate_email(self, field): - if User.query.filter_by(email=field.data.lower()).first(): + if User.query.filter_by(email=field.data).first(): raise ValidationError('Email already registered.') def validate_username(self, field): @@ -35,34 +34,39 @@ def validate_username(self, field): raise ValidationError('Username already in use.') -class ChangePasswordForm(FlaskForm): - old_password = PasswordField('Old password', validators=[DataRequired()]) +class ChangePasswordForm(Form): + old_password = PasswordField('Old password', validators=[Required()]) password = PasswordField('New password', validators=[ - DataRequired(), EqualTo('password2', message='Passwords must match.')]) - password2 = PasswordField('Confirm new password', - validators=[DataRequired()]) + Required(), EqualTo('password2', message='Passwords must match')]) + password2 = PasswordField('Confirm new password', validators=[Required()]) submit = SubmitField('Update Password') -class PasswordResetRequestForm(FlaskForm): - email = StringField('Email', validators=[DataRequired(), Length(1, 64), +class PasswordResetRequestForm(Form): + email = StringField('Email', validators=[Required(), Length(1, 64), Email()]) submit = SubmitField('Reset Password') -class PasswordResetForm(FlaskForm): +class PasswordResetForm(Form): + email = StringField('Email', validators=[Required(), Length(1, 64), + Email()]) password = PasswordField('New Password', validators=[ - DataRequired(), EqualTo('password2', message='Passwords must match')]) - password2 = PasswordField('Confirm password', validators=[DataRequired()]) + Required(), EqualTo('password2', message='Passwords must match')]) + password2 = PasswordField('Confirm password', validators=[Required()]) submit = SubmitField('Reset Password') + def validate_email(self, field): + if User.query.filter_by(email=field.data).first() is None: + raise ValidationError('Unknown email address.') + -class ChangeEmailForm(FlaskForm): - email = StringField('New Email', validators=[DataRequired(), Length(1, 64), +class ChangeEmailForm(Form): + email = StringField('New Email', validators=[Required(), Length(1, 64), Email()]) - password = PasswordField('Password', validators=[DataRequired()]) + password = PasswordField('Password', validators=[Required()]) submit = SubmitField('Update Email Address') def validate_email(self, field): - if User.query.filter_by(email=field.data.lower()).first(): + if User.query.filter_by(email=field.data).first(): raise ValidationError('Email already registered.') diff --git a/app/auth/views.py b/app/auth/views.py index 7ddd75ea2..a72684a00 100644 --- a/app/auth/views.py +++ b/app/auth/views.py @@ -1,5 +1,5 @@ from flask import render_template, redirect, request, url_for, flash -from flask_login import login_user, logout_user, login_required, \ +from flask.ext.login import login_user, logout_user, login_required, \ current_user from . import auth from .. import db @@ -14,8 +14,7 @@ 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[:5] != 'auth.' \ and request.endpoint != 'static': return redirect(url_for('auth.unconfirmed')) @@ -31,14 +30,11 @@ def unconfirmed(): def login(): form = LoginForm() if form.validate_on_submit(): - user = User.query.filter_by(email=form.email.data.lower()).first() + 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 email or password.') + return redirect(request.args.get('next') or url_for('main.index')) + flash('Invalid username or password.') return render_template('auth/login.html', form=form) @@ -54,7 +50,7 @@ def logout(): def register(): form = RegistrationForm() if form.validate_on_submit(): - user = User(email=form.email.data.lower(), + user = User(email=form.email.data, username=form.username.data, password=form.password.data) db.session.add(user) @@ -73,7 +69,6 @@ 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.') @@ -98,7 +93,6 @@ def change_password(): 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: @@ -112,12 +106,13 @@ def password_reset_request(): return redirect(url_for('main.index')) form = PasswordResetRequestForm() if form.validate_on_submit(): - user = User.query.filter_by(email=form.email.data.lower()).first() + 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) + user=user, token=token, + next=request.args.get('next')) flash('An email with instructions to reset your password has been ' 'sent to you.') return redirect(url_for('auth.login')) @@ -130,8 +125,10 @@ def password_reset(token): return redirect(url_for('main.index')) form = PasswordResetForm() if form.validate_on_submit(): - if User.reset_password(token, form.password.data): - db.session.commit() + user = User.query.filter_by(email=form.email.data).first() + if user is None: + return redirect(url_for('main.index')) + if user.reset_password(token, form.password.data): flash('Your password has been updated.') return redirect(url_for('auth.login')) else: @@ -139,13 +136,13 @@ def password_reset(token): return render_template('auth/reset_password.html', form=form) -@auth.route('/change_email', methods=['GET', 'POST']) +@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.lower() + 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', @@ -158,11 +155,10 @@ def change_email_request(): return render_template("auth/change_email.html", form=form) -@auth.route('/change_email/') +@auth.route('/change-email/') @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.') diff --git a/app/decorators.py b/app/decorators.py index 14ddc0347..2707dc5e2 100644 --- a/app/decorators.py +++ b/app/decorators.py @@ -1,6 +1,6 @@ from functools import wraps from flask import abort -from flask_login import current_user +from flask.ext.login import current_user from .models import Permission @@ -16,4 +16,4 @@ def decorated_function(*args, **kwargs): def admin_required(f): - return permission_required(Permission.ADMIN)(f) + return permission_required(Permission.ADMINISTER)(f) diff --git a/app/email.py b/app/email.py index 0f6ac520b..c48f5eb5c 100644 --- a/app/email.py +++ b/app/email.py @@ -1,6 +1,6 @@ from threading import Thread from flask import current_app, render_template -from flask_mail import Message +from flask.ext.mail import Message from . import mail diff --git a/app/fake.py b/app/fake.py deleted file mode 100644 index bdf52fc31..000000000 --- a/app/fake.py +++ /dev/null @@ -1,37 +0,0 @@ -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() diff --git a/app/main/forms.py b/app/main/forms.py index 770edb2b4..bc1399adb 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -1,32 +1,31 @@ -from flask_wtf import FlaskForm +from flask.ext.wtf import Form from wtforms import StringField, TextAreaField, BooleanField, SelectField,\ SubmitField -from wtforms.validators import DataRequired, Length, Email, Regexp +from wtforms.validators import Required, Length, Email, Regexp from wtforms import ValidationError -from flask_pagedown.fields import PageDownField +from flask.ext.pagedown.fields import PageDownField from ..models import Role, User -class NameForm(FlaskForm): - name = StringField('What is your name?', validators=[DataRequired()]) +class NameForm(Form): + name = StringField('What is your name?', validators=[Required()]) submit = SubmitField('Submit') -class EditProfileForm(FlaskForm): +class EditProfileForm(Form): 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), +class EditProfileAdminForm(Form): + email = StringField('Email', validators=[Required(), 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')]) + Required(), 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)]) @@ -51,11 +50,11 @@ def validate_username(self, field): raise ValidationError('Username already in use.') -class PostForm(FlaskForm): - body = PageDownField("What's on your mind?", validators=[DataRequired()]) +class PostForm(Form): + body = PageDownField("What's on your mind?", validators=[Required()]) submit = SubmitField('Submit') -class CommentForm(FlaskForm): - body = StringField('Enter your comment', validators=[DataRequired()]) +class CommentForm(Form): + body = StringField('Enter your comment', validators=[Required()]) submit = SubmitField('Submit') diff --git a/app/main/views.py b/app/main/views.py index 3a7add38b..83092d7ad 100644 --- a/app/main/views.py +++ b/app/main/views.py @@ -1,7 +1,7 @@ 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 flask.ext.login import login_required, current_user +from flask.ext.sqlalchemy import get_debug_queries from . import main from .forms import EditProfileForm, EditProfileAdminForm, PostForm,\ CommentForm @@ -35,11 +35,11 @@ def server_shutdown(): @main.route('/', methods=['GET', 'POST']) def index(): form = PostForm() - if current_user.can(Permission.WRITE) and form.validate_on_submit(): + if current_user.can(Permission.WRITE_ARTICLES) 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 @@ -50,7 +50,7 @@ def index(): else: query = Post.query pagination = query.order_by(Post.timestamp.desc()).paginate( - page=page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'], + 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, @@ -62,7 +62,7 @@ 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=page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'], + 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, @@ -77,8 +77,7 @@ def edit_profile(): 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() + db.session.add(current_user) flash('Your profile has been updated.') return redirect(url_for('.user', username=current_user.username)) form.name.data = current_user.name @@ -102,7 +101,6 @@ def edit_profile_admin(id): 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 @@ -124,7 +122,6 @@ def post(id): 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) @@ -132,7 +129,7 @@ def post(id): page = (post.comments.count() - 1) // \ current_app.config['FLASKY_COMMENTS_PER_PAGE'] + 1 pagination = post.comments.order_by(Comment.timestamp.asc()).paginate( - page=page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'], + 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, @@ -144,13 +141,12 @@ def post(id): def edit(id): post = Post.query.get_or_404(id) if current_user != post.author and \ - not current_user.can(Permission.ADMIN): + not current_user.can(Permission.ADMINISTER): 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 @@ -169,7 +165,6 @@ def follow(username): 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)) @@ -186,7 +181,6 @@ def unfollow(username): 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)) @@ -199,7 +193,7 @@ def followers(username): return redirect(url_for('.index')) page = request.args.get('page', 1, type=int) pagination = user.followers.paginate( - page=page, per_page=current_app.config['FLASKY_FOLLOWERS_PER_PAGE'], + 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] @@ -208,7 +202,7 @@ def followers(username): follows=follows) -@main.route('/followed_by/') +@main.route('/followed-by/') def followed_by(username): user = User.query.filter_by(username=username).first() if user is None: @@ -216,7 +210,7 @@ def followed_by(username): return redirect(url_for('.index')) page = request.args.get('page', 1, type=int) pagination = user.followed.paginate( - page=page, per_page=current_app.config['FLASKY_FOLLOWERS_PER_PAGE'], + 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] @@ -243,11 +237,11 @@ def show_followed(): @main.route('/moderate') @login_required -@permission_required(Permission.MODERATE) +@permission_required(Permission.MODERATE_COMMENTS) def moderate(): page = request.args.get('page', 1, type=int) pagination = Comment.query.order_by(Comment.timestamp.desc()).paginate( - page=page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'], + page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'], error_out=False) comments = pagination.items return render_template('moderate.html', comments=comments, @@ -256,23 +250,21 @@ def moderate(): @main.route('/moderate/enable/') @login_required -@permission_required(Permission.MODERATE) +@permission_required(Permission.MODERATE_COMMENTS) 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/') @login_required -@permission_required(Permission.MODERATE) +@permission_required(Permission.MODERATE_COMMENTS) 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))) diff --git a/app/models.py b/app/models.py index 8832f76cc..3f16ca340 100644 --- a/app/models.py +++ b/app/models.py @@ -5,17 +5,17 @@ from markdown import markdown import bleach from flask import current_app, request, url_for -from flask_login import UserMixin, AnonymousUserMixin +from flask.ext.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 + FOLLOW = 0x01 + COMMENT = 0x02 + WRITE_ARTICLES = 0x04 + MODERATE_COMMENTS = 0x08 + ADMINISTER = 0x80 class Role(db.Model): @@ -26,47 +26,27 @@ class Role(db.Model): 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], + 'User': (Permission.FOLLOW | + Permission.COMMENT | + Permission.WRITE_ARTICLES, True), + 'Moderator': (Permission.FOLLOW | + Permission.COMMENT | + Permission.WRITE_ARTICLES | + Permission.MODERATE_COMMENTS, False), + 'Administrator': (0xff, False) } - 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) + role.permissions = roles[r][0] + role.default = roles[r][1] 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 '' % self.name @@ -107,6 +87,28 @@ class User(UserMixin, db.Model): cascade='all, delete-orphan') comments = db.relationship('Comment', backref='author', lazy='dynamic') + @staticmethod + def generate_fake(count=100): + from sqlalchemy.exc import IntegrityError + from random import seed + import forgery_py + + seed() + for i in range(count): + u = User(email=forgery_py.internet.email_address(), + username=forgery_py.internet.user_name(True), + password=forgery_py.lorem_ipsum.word(), + confirmed=True, + name=forgery_py.name.full_name(), + location=forgery_py.address.city(), + about_me=forgery_py.lorem_ipsum.sentence(), + member_since=forgery_py.date.date(True)) + db.session.add(u) + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + @staticmethod def add_self_follows(): for user in User.query.all(): @@ -119,12 +121,13 @@ 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() + self.role = Role.query.filter_by(permissions=0xff).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) + self.avatar_hash = hashlib.md5( + self.email.encode('utf-8')).hexdigest() + self.followed.append(Follow(followed=self)) @property def password(self): @@ -139,12 +142,12 @@ def verify_password(self, 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') + return s.dumps({'confirm': self.id}) def confirm(self, token): s = Serializer(current_app.config['SECRET_KEY']) try: - data = s.loads(token.encode('utf-8')) + data = s.loads(token) except: return False if data.get('confirm') != self.id: @@ -155,31 +158,28 @@ def confirm(self, token): def generate_reset_token(self, expiration=3600): s = Serializer(current_app.config['SECRET_KEY'], expiration) - return s.dumps({'reset': self.id}).decode('utf-8') + return s.dumps({'reset': self.id}) - @staticmethod - def reset_password(token, new_password): + def reset_password(self, token, new_password): s = Serializer(current_app.config['SECRET_KEY']) try: - data = s.loads(token.encode('utf-8')) + data = s.loads(token) except: return False - user = User.query.get(data.get('reset')) - if user is None: + if data.get('reset') != self.id: return False - user.password = new_password - db.session.add(user) + self.password = new_password + db.session.add(self) 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') + return s.dumps({'change_email': self.id, 'new_email': new_email}) def change_email(self, token): s = Serializer(current_app.config['SECRET_KEY']) try: - data = s.loads(token.encode('utf-8')) + data = s.loads(token) except: return False if data.get('change_email') != self.id: @@ -190,26 +190,29 @@ def change_email(self, token): if self.query.filter_by(email=new_email).first() is not None: return False self.email = new_email - self.avatar_hash = self.gravatar_hash() + self.avatar_hash = hashlib.md5( + self.email.encode('utf-8')).hexdigest() db.session.add(self) return True - def can(self, perm): - return self.role is not None and self.role.has_permission(perm) + def can(self, permissions): + return self.role is not None and \ + (self.role.permissions & permissions) == permissions def is_administrator(self): - return self.can(Permission.ADMIN) + return self.can(Permission.ADMINISTER) 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() + if request.is_secure: + url = 'https://secure.gravatar.com/avatar' + else: + url = 'http://www.gravatar.com/avatar' + hash = self.avatar_hash or hashlib.md5( + self.email.encode('utf-8')).hexdigest() return '{url}/{hash}?s={size}&d={default}&r={rating}'.format( url=url, hash=hash, size=size, default=default, rating=rating) @@ -224,14 +227,10 @@ def unfollow(self, user): 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 @@ -242,13 +241,13 @@ def followed_posts(self): def to_json(self): json_user = { - 'url': url_for('api.get_user', id=self.id), + 'url': url_for('api.get_post', id=self.id, _external=True), '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), + 'posts': url_for('api.get_user_posts', id=self.id, _external=True), + 'followed_posts': url_for('api.get_user_followed_posts', + id=self.id, _external=True), 'post_count': self.posts.count() } return json_user @@ -256,7 +255,7 @@ def to_json(self): 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') + return s.dumps({'id': self.id}).decode('ascii') @staticmethod def verify_auth_token(token): @@ -295,6 +294,21 @@ class Post(db.Model): author_id = db.Column(db.Integer, db.ForeignKey('users.id')) comments = db.relationship('Comment', backref='post', lazy='dynamic') + @staticmethod + def generate_fake(count=100): + from random import seed, randint + import forgery_py + + seed() + user_count = User.query.count() + for i in range(count): + u = User.query.offset(randint(0, user_count - 1)).first() + p = Post(body=forgery_py.lorem_ipsum.sentences(randint(1, 5)), + timestamp=forgery_py.date.date(True), + author=u) + db.session.add(p) + db.session.commit() + @staticmethod def on_changed_body(target, value, oldvalue, initiator): allowed_tags = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', @@ -306,12 +320,14 @@ def on_changed_body(target, value, oldvalue, initiator): def to_json(self): json_post = { - 'url': url_for('api.get_post', id=self.id), + 'url': url_for('api.get_post', id=self.id, _external=True), '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), + 'author': url_for('api.get_user', id=self.author_id, + _external=True), + 'comments': url_for('api.get_post_comments', id=self.id, + _external=True), 'comment_count': self.comments.count() } return json_post @@ -347,12 +363,13 @@ def on_changed_body(target, value, oldvalue, initiator): 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), + 'url': url_for('api.get_comment', id=self.id, _external=True), + 'post': url_for('api.get_post', id=self.post_id, _external=True), 'body': self.body, 'body_html': self.body_html, 'timestamp': self.timestamp, - 'author_url': url_for('api.get_user', id=self.author_id), + 'author': url_for('api.get_user', id=self.author_id, + _external=True), } return json_comment diff --git a/app/templates/auth/unconfirmed.html b/app/templates/auth/unconfirmed.html index 75bf19a48..cdf194f77 100644 --- a/app/templates/auth/unconfirmed.html +++ b/app/templates/auth/unconfirmed.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}Flasky - Confirm your account{% endblock %} +{% block title %}Flasky - Confirm your accont{% endblock %} {% block page_content %}
- {% if current_user.can(Permission.WRITE) %} + {% if current_user.can(Permission.WRITE_ARTICLES) %} {{ wtf.quick_form(form) }} {% endif %}
@@ -17,7 +17,7 @@

Hello, {% if current_user.is_authenticated %}{{ current_user.username }}{% e {% include '_posts.html' %} diff --git a/app/templates/user.html b/app/templates/user.html index 80e865e4b..25fe388ed 100644 --- a/app/templates/user.html +++ b/app/templates/user.html @@ -12,7 +12,7 @@

{{ user.username }}

{% if user.name %}{{ user.name }}
{% endif %} {% if user.location %} - from {{ user.location }}
+ From {{ user.location }}
{% endif %}

{% endif %} diff --git a/boot.sh b/boot.sh deleted file mode 100755 index a6ca9f900..000000000 --- a/boot.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/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 diff --git a/config.py b/config.py index 6c70ce89f..abb5a8442 100644 --- a/config.py +++ b/config.py @@ -4,22 +4,21 @@ 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'] + SSL_DISABLE = False + SQLALCHEMY_COMMIT_ON_TEARDOWN = True + SQLALCHEMY_RECORD_QUERIES = True + MAIL_SERVER = 'smtp.googlemail.com' + MAIL_PORT = 587 + MAIL_USE_TLS = True 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_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 + FLASKY_SLOW_DB_QUERY_TIME=0.5 @staticmethod def init_app(app): @@ -35,14 +34,13 @@ class DevelopmentConfig(Config): class TestingConfig(Config): TESTING = True SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \ - 'sqlite://' + 'sqlite:///' + os.path.join(basedir, 'data-test.sqlite') WTF_CSRF_ENABLED = False class ProductionConfig(Config): SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ 'sqlite:///' + os.path.join(basedir, 'data.sqlite') - SERVER_NAME = os.environ['SERVER_NAME'] # configure the domain name in use @classmethod def init_app(cls, app): @@ -69,37 +67,21 @@ def init_app(cls, app): class HerokuConfig(ProductionConfig): - SSL_REDIRECT = True if os.environ.get('DYNO') else False + SSL_DISABLE = bool(os.environ.get('SSL_DISABLE')) @classmethod def init_app(cls, app): ProductionConfig.init_app(app) - # handle reverse proxy server headers - try: - from werkzeug.middleware.proxy_fix import ProxyFix - except ImportError: - from werkzeug.contrib.fixers import ProxyFix + # handle 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) + file_handler.setLevel(logging.WARNING) app.logger.addHandler(file_handler) @@ -112,7 +94,7 @@ def init_app(cls, app): import logging from logging.handlers import SysLogHandler syslog_handler = SysLogHandler() - syslog_handler.setLevel(logging.INFO) + syslog_handler.setLevel(logging.WARNING) app.logger.addHandler(syslog_handler) @@ -121,7 +103,6 @@ def init_app(cls, app): 'testing': TestingConfig, 'production': ProductionConfig, 'heroku': HerokuConfig, - 'docker': DockerConfig, 'unix': UnixConfig, 'default': DevelopmentConfig diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 858fa3eda..000000000 --- a/docker-compose.yml +++ /dev/null @@ -1,14 +0,0 @@ -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 diff --git a/flasky.py b/manage.py old mode 100644 new mode 100755 similarity index 55% rename from flasky.py rename to manage.py index a2ec114ff..f049f6c7b --- a/flasky.py +++ b/manage.py @@ -1,48 +1,44 @@ +#!/usr/bin/env python 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 +if os.path.exists('.env'): + print('Importing environment from .env...') + for line in open('.env'): + var = line.strip().split('=') + if len(var) == 2: + os.environ[var[0]] = var[1] + from app import create_app, db from app.models import User, Follow, Role, Permission, Post, Comment +from flask.ext.script import Manager, Shell +from flask.ext.migrate import Migrate, MigrateCommand app = create_app(os.getenv('FLASK_CONFIG') or 'default') +manager = Manager(app) migrate = Migrate(app, db) -@app.shell_context_processor def make_shell_context(): - return dict(db=db, User=User, Follow=Follow, Role=Role, + return dict(app=app, db=db, User=User, Follow=Follow, Role=Role, Permission=Permission, Post=Post, Comment=Comment) +manager.add_command("shell", Shell(make_context=make_shell_context)) +manager.add_command('db', MigrateCommand) -@app.cli.command() -@click.option('--coverage/--no-coverage', default=False, - help='Run tests under code coverage.') -@click.argument('test_names', nargs=-1) -def test(coverage, test_names): +@manager.command +def test(coverage=False): """Run the unit tests.""" if coverage and not os.environ.get('FLASK_COVERAGE'): - import subprocess + import sys os.environ['FLASK_COVERAGE'] = '1' - sys.exit(subprocess.call(sys.argv)) - + os.execvp(sys.executable, [sys.executable] + sys.argv) import unittest - if test_names: - tests = unittest.TestLoader().loadTestsFromNames(test_names) - else: - tests = unittest.TestLoader().discover('tests') + tests = unittest.TestLoader().discover('tests') unittest.TextTestRunner(verbosity=2).run(tests) if COV: COV.stop() @@ -56,12 +52,8 @@ def test(coverage, test_names): 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): +@manager.command +def profile(length=25, profile_dir=None): """Start the application under the code profiler.""" from werkzeug.contrib.profiler import ProfilerMiddleware app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[length], @@ -69,14 +61,21 @@ def profile(length, profile_dir): app.run() -@app.cli.command() +@manager.command def deploy(): """Run deployment tasks.""" + from flask.ext.migrate import upgrade + from app.models import Role, User + # migrate database to latest revision upgrade() - # create or update user roles + # create user roles Role.insert_roles() - # ensure all users are following themselves + # create self-follows for all users User.add_self_follows() + + +if __name__ == '__main__': + manager.run() diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index e8858f6cb..000000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -# this requirements file is used by Heroku -# requirements for other configurations are located in the requirements subdirectory --r requirements/heroku.txt diff --git a/requirements/common.txt b/requirements/common.txt index 8cbd6cb8d..5eeaf2ca4 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -1,30 +1,24 @@ -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.1.1 -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 +Flask==0.10.1 +Flask-Bootstrap==3.0.3.1 +Flask-HTTPAuth==2.7.0 +Flask-Login==0.3.1 +Flask-Mail==0.9.0 +Flask-Migrate==1.1.0 +Flask-Moment==0.2.1 +Flask-PageDown==0.1.4 +Flask-SQLAlchemy==1.0 +Flask-Script==0.6.6 +Flask-WTF==0.9.4 +Jinja2==2.7.1 +Mako==0.9.1 +Markdown==2.3.1 +MarkupSafe==0.18 +SQLAlchemy==0.9.9 +WTForms==1.0.5 +Werkzeug==0.10.4 +alembic==0.6.2 +bleach==1.4.0 +blinker==1.3 +html5lib==1.0b3 +itsdangerous==0.23 +six==1.4.1 diff --git a/requirements/dev.txt b/requirements/dev.txt index 2642ea720..44fc970f0 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,11 +1,8 @@ -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.141.0 -urllib3==1.22 +ForgeryPy==0.1 +Pygments==1.6 +colorama==0.2.7 +coverage==3.7.1 +httpie==0.7.2 +requests==2.1.0 +selenium==2.45.0 diff --git a/requirements/docker.txt b/requirements/docker.txt deleted file mode 100644 index 0aecafcad..000000000 --- a/requirements/docker.txt +++ /dev/null @@ -1,3 +0,0 @@ --r common.txt -gunicorn==19.7.1 -pymysql==0.7.11 diff --git a/requirements/heroku.txt b/requirements/heroku.txt index ada4e3a35..4adc86885 100644 --- a/requirements/heroku.txt +++ b/requirements/heroku.txt @@ -1,4 +1,4 @@ -r prod.txt -Flask-SSLify==0.1.5 -gunicorn==19.7.1 -psycopg2==2.7.3 +Flask-SSLify==0.1.4 +gunicorn==18.0 +psycopg2==2.5.1 diff --git a/tests/test_api.py b/tests/test_api.py index 666d3c707..bdff1ea48 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,6 +2,7 @@ import json import re from base64 import b64encode +from flask import url_for from app import create_app, db from app.models import User, Role, Post, Comment @@ -32,14 +33,14 @@ 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') + self.assertTrue(response.status_code == 404) + json_response = json.loads(response.data.decode('utf-8')) + self.assertTrue(json_response['error'] == 'not found') def test_no_auth(self): - response = self.client.get('/api/v1/posts/', + response = self.client.get(url_for('api.get_posts'), content_type='application/json') - self.assertEqual(response.status_code, 401) + self.assertTrue(response.status_code == 200) def test_bad_auth(self): # add a user @@ -52,9 +53,9 @@ def test_bad_auth(self): # authenticate with bad password response = self.client.get( - '/api/v1/posts/', + url_for('api.get_posts'), headers=self.get_api_headers('john@example.com', 'dog')) - self.assertEqual(response.status_code, 401) + self.assertTrue(response.status_code == 401) def test_token_auth(self): # add a user @@ -67,30 +68,30 @@ def test_token_auth(self): # issue a request with a bad token response = self.client.get( - '/api/v1/posts/', + url_for('api.get_posts'), headers=self.get_api_headers('bad-token', '')) - self.assertEqual(response.status_code, 401) + self.assertTrue(response.status_code == 401) # get a token - response = self.client.post( - '/api/v1/tokens/', + response = self.client.get( + url_for('api.get_token'), 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.assertTrue(response.status_code == 200) + json_response = json.loads(response.data.decode('utf-8')) self.assertIsNotNone(json_response.get('token')) token = json_response['token'] # issue a request with the token response = self.client.get( - '/api/v1/posts/', + url_for('api.get_posts'), headers=self.get_api_headers(token, '')) - self.assertEqual(response.status_code, 200) + self.assertTrue(response.status_code == 200) def test_anonymous(self): response = self.client.get( - '/api/v1/posts/', + url_for('api.get_posts'), headers=self.get_api_headers('', '')) - self.assertEqual(response.status_code, 401) + self.assertTrue(response.status_code == 200) def test_unconfirmed_account(self): # add an unconfirmed user @@ -103,9 +104,9 @@ def test_unconfirmed_account(self): # get list of posts with the unconfirmed account response = self.client.get( - '/api/v1/posts/', + url_for('api.get_posts'), headers=self.get_api_headers('john@example.com', 'cat')) - self.assertEqual(response.status_code, 403) + self.assertTrue(response.status_code == 403) def test_posts(self): # add a user @@ -118,17 +119,17 @@ def test_posts(self): # write an empty post response = self.client.post( - '/api/v1/posts/', + url_for('api.new_post'), headers=self.get_api_headers('john@example.com', 'cat'), data=json.dumps({'body': ''})) - self.assertEqual(response.status_code, 400) + self.assertTrue(response.status_code == 400) # write a post response = self.client.post( - '/api/v1/posts/', + url_for('api.new_post'), headers=self.get_api_headers('john@example.com', 'cat'), data=json.dumps({'body': 'body of the *blog* post'})) - self.assertEqual(response.status_code, 201) + self.assertTrue(response.status_code == 201) url = response.headers.get('Location') self.assertIsNotNone(url) @@ -136,44 +137,44 @@ def test_posts(self): 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'], + self.assertTrue(response.status_code == 200) + json_response = json.loads(response.data.decode('utf-8')) + self.assertTrue(json_response['url'] == url) + self.assertTrue(json_response['body'] == 'body of the *blog* post') + self.assertTrue(json_response['body_html'] == '

body of the blog post

') json_post = json_response # get the post from the user response = self.client.get( - '/api/v1/users/{}/posts/'.format(u.id), + url_for('api.get_user_posts', id=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.assertTrue(response.status_code == 200) + json_response = json.loads(response.data.decode('utf-8')) self.assertIsNotNone(json_response.get('posts')) - self.assertEqual(json_response.get('count', 0), 1) - self.assertEqual(json_response['posts'][0], json_post) + self.assertTrue(json_response.get('count', 0) == 1) + self.assertTrue(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), + url_for('api.get_user_followed_posts', id=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.assertTrue(response.status_code == 200) + json_response = json.loads(response.data.decode('utf-8')) self.assertIsNotNone(json_response.get('posts')) - self.assertEqual(json_response.get('count', 0), 1) - self.assertEqual(json_response['posts'][0], json_post) + self.assertTrue(json_response.get('count', 0) == 1) + self.assertTrue(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'], '

updated body

') + self.assertTrue(response.status_code == 200) + json_response = json.loads(response.data.decode('utf-8')) + self.assertTrue(json_response['url'] == url) + self.assertTrue(json_response['body'] == 'updated body') + self.assertTrue(json_response['body_html'] == '

updated body

') def test_users(self): # add two users @@ -188,17 +189,17 @@ def test_users(self): # get users response = self.client.get( - '/api/v1/users/{}'.format(u1.id), + url_for('api.get_user', id=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') + self.assertTrue(response.status_code == 200) + json_response = json.loads(response.data.decode('utf-8')) + self.assertTrue(json_response['username'] == 'john') response = self.client.get( - '/api/v1/users/{}'.format(u2.id), + url_for('api.get_user', id=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') + self.assertTrue(response.status_code == 200) + json_response = json.loads(response.data.decode('utf-8')) + self.assertTrue(json_response['username'] == 'susan') def test_comments(self): # add two users @@ -218,26 +219,26 @@ def test_comments(self): # write a comment response = self.client.post( - '/api/v1/posts/{}/comments/'.format(post.id), + url_for('api.new_post_comment', id=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)) + self.assertTrue(response.status_code == 201) + json_response = json.loads(response.data.decode('utf-8')) url = response.headers.get('Location') self.assertIsNotNone(url) - self.assertEqual(json_response['body'], + self.assertTrue(json_response['body'] == 'Good [post](http://example.com)!') - self.assertEqual( - re.sub('<.*?>', '', json_response['body_html']), 'Good post!') + self.assertTrue( + 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'], + self.assertTrue(response.status_code == 200) + json_response = json.loads(response.data.decode('utf-8')) + self.assertTrue(json_response['url'] == url) + self.assertTrue(json_response['body'] == 'Good [post](http://example.com)!') # add another comment @@ -247,18 +248,18 @@ def test_comments(self): # get the two comments from the post response = self.client.get( - '/api/v1/posts/{}/comments/'.format(post.id), + url_for('api.get_post_comments', id=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) + self.assertTrue(response.status_code == 200) + json_response = json.loads(response.data.decode('utf-8')) + self.assertIsNotNone(json_response.get('posts')) + self.assertTrue(json_response.get('count', 0) == 2) # get all the comments response = self.client.get( - '/api/v1/posts/{}/comments/'.format(post.id), + url_for('api.get_comments', id=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) + self.assertTrue(response.status_code == 200) + json_response = json.loads(response.data.decode('utf-8')) + self.assertIsNotNone(json_response.get('posts')) + self.assertTrue(json_response.get('count', 0) == 2) diff --git a/tests/test_client.py b/tests/test_client.py index bc6f5ca75..b5955208c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,5 +1,6 @@ import re import unittest +from flask import url_for from app import create_app, db from app.models import User, Role @@ -18,45 +19,36 @@ def tearDown(self): 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)) + response = self.client.get(url_for('main.index')) + self.assertTrue(b'Stranger' in response.data) def test_register_and_login(self): # register a new account - response = self.client.post('/auth/register', data={ + response = self.client.post(url_for('auth.register'), data={ 'email': 'john@example.com', 'username': 'john', 'password': 'cat', 'password2': 'cat' }) - self.assertEqual(response.status_code, 302) + self.assertTrue(response.status_code == 302) # login with the new account - response = self.client.post('/auth/login', data={ + response = self.client.post(url_for('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(re.search(b'Hello,\s+john!', response.data)) self.assertTrue( - 'You have not confirmed your account yet' in response.get_data( - as_text=True)) + b'You have not confirmed your account yet' in response.data) # 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), + response = self.client.get(url_for('auth.confirm', token=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)) + b'You have confirmed your account' in response.data) # 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)) + response = self.client.get(url_for('auth.logout'), follow_redirects=True) + self.assertTrue(b'You have been logged out' in response.data) diff --git a/tests/test_selenium.py b/tests/test_selenium.py index f0eb00ffc..e5fe640cc 100644 --- a/tests/test_selenium.py +++ b/tests/test_selenium.py @@ -3,7 +3,7 @@ import time import unittest from selenium import webdriver -from app import create_app, db, fake +from app import create_app, db from app.models import Role, User, Post @@ -12,11 +12,9 @@ class SeleniumTestCase(unittest.TestCase): @classmethod def setUpClass(cls): - # start Chrome - options = webdriver.ChromeOptions() - options.add_argument('headless') + # start Firefox try: - cls.client = webdriver.Chrome(chrome_options=options) + cls.client = webdriver.Firefox() except: pass @@ -35,11 +33,11 @@ def setUpClass(cls): # create the database and populate with some fake data db.create_all() Role.insert_roles() - fake.users(10) - fake.posts(10) + User.generate_fake(10) + Post.generate_fake(10) # add an administrator user - admin_role = Role.query.filter_by(name='Administrator').first() + admin_role = Role.query.filter_by(permissions=0xff).first() admin = User(email='john@example.com', username='john', password='cat', role=admin_role, confirmed=True) @@ -47,9 +45,7 @@ def setUpClass(cls): 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() + threading.Thread(target=cls.app.run).start() # give the server a second to ensure it is up time.sleep(1) @@ -59,8 +55,7 @@ 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() + cls.client.close() # destroy database db.drop_all() @@ -84,7 +79,7 @@ def test_admin_home_page(self): # navigate to login page self.client.find_element_by_link_text('Log In').click() - self.assertIn('

Login

', self.client.page_source) + self.assertTrue('

Login

' in self.client.page_source) # login self.client.find_element_by_name('email').\ @@ -95,4 +90,4 @@ def test_admin_home_page(self): # navigate to the user's profile page self.client.find_element_by_link_text('Profile').click() - self.assertIn('

john

', self.client.page_source) + self.assertTrue('

john

' in self.client.page_source) diff --git a/tests/test_user_model.py b/tests/test_user_model.py index 526abdbdd..808966bfc 100644 --- a/tests/test_user_model.py +++ b/tests/test_user_model.py @@ -66,16 +66,18 @@ def test_valid_reset_token(self): db.session.add(u) db.session.commit() token = u.generate_reset_token() - self.assertTrue(User.reset_password(token, 'dog')) + self.assertTrue(u.reset_password(token, 'dog')) self.assertTrue(u.verify_password('dog')) def test_invalid_reset_token(self): - u = User(password='cat') - db.session.add(u) + u1 = User(password='cat') + u2 = User(password='dog') + db.session.add(u1) + db.session.add(u2) db.session.commit() - token = u.generate_reset_token() - self.assertFalse(User.reset_password(token + 'a', 'horse')) - self.assertTrue(u.verify_password('cat')) + token = u1.generate_reset_token() + self.assertFalse(u2.reset_password(token, 'horse')) + self.assertTrue(u2.verify_password('dog')) def test_valid_email_change_token(self): u = User(email='john@example.com', password='cat') @@ -105,39 +107,14 @@ def test_duplicate_email_change_token(self): self.assertFalse(u2.change_email(token)) self.assertTrue(u2.email == 'susan@example.org') - def test_user_role(self): + def test_roles_and_permissions(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)) + self.assertTrue(u.can(Permission.WRITE_ARTICLES)) + self.assertFalse(u.can(Permission.MODERATE_COMMENTS)) 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') @@ -164,11 +141,15 @@ def test_gravatar(self): 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/' + + with self.app.test_request_context('/', base_url='https://example.com/'): + gravatar_ssl = u.gravatar() + self.assertTrue('http://www.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) + self.assertTrue('https://secure.gravatar.com/avatar/' + + 'd4c74594d841139328695756648b6bd6' in gravatar_ssl) def test_follows(self): u1 = User(email='john@example.com', password='cat') @@ -206,14 +187,3 @@ def test_follows(self): 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'])