From 64ed7642b924f6942b2efe9766b36b3f4fe62430 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Mon, 9 Oct 2017 00:09:22 -0700 Subject: [PATCH 1/9] Chapter 15: A Better Application Structure (v0.15) --- app/__init__.py | 100 +++--- app/auth/__init__.py | 5 + app/auth/email.py | 14 + app/{ => auth}/forms.py | 28 +- app/auth/routes.py | 82 +++++ app/cli.py | 59 ++-- app/email.py | 19 +- app/errors.py | 13 - app/errors/__init__.py | 5 + app/errors/handlers.py | 14 + app/main/__init__.py | 5 + app/main/forms.py | 29 ++ app/main/routes.py | 134 ++++++++ app/models.py | 8 +- app/routes.py | 206 ----------- app/templates/_post.html | 4 +- app/templates/{ => auth}/login.html | 4 +- app/templates/{ => auth}/register.html | 0 app/templates/{ => auth}/reset_password.html | 0 .../{ => auth}/reset_password_request.html | 0 app/templates/base.html | 12 +- app/templates/email/reset_password.html | 4 +- app/templates/email/reset_password.txt | 2 +- app/templates/{ => errors}/404.html | 2 +- app/templates/{ => errors}/500.html | 2 +- app/templates/user.html | 6 +- app/translate.py | 9 +- app/translations/es/LC_MESSAGES/messages.po | 319 +++++++++--------- config.py | 3 + microblog.py | 5 +- requirements.txt | 34 ++ tests.py | 13 +- 32 files changed, 615 insertions(+), 525 deletions(-) create mode 100644 app/auth/__init__.py create mode 100644 app/auth/email.py rename app/{ => auth}/forms.py (67%) create mode 100644 app/auth/routes.py delete mode 100644 app/errors.py create mode 100644 app/errors/__init__.py create mode 100644 app/errors/handlers.py create mode 100644 app/main/__init__.py create mode 100644 app/main/forms.py create mode 100644 app/main/routes.py delete mode 100644 app/routes.py rename app/templates/{ => auth}/login.html (61%) rename app/templates/{ => auth}/register.html (100%) rename app/templates/{ => auth}/reset_password.html (100%) rename app/templates/{ => auth}/reset_password_request.html (100%) rename app/templates/{ => errors}/404.html (59%) rename app/templates/{ => errors}/500.html (75%) create mode 100644 requirements.txt diff --git a/app/__init__.py b/app/__init__.py index 0a6b530..e92cd70 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,7 +1,7 @@ import logging from logging.handlers import SMTPHandler, RotatingFileHandler import os -from flask import Flask, request +from flask import Flask, request, current_app from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate from flask_login import LoginManager @@ -11,50 +11,74 @@ from flask_moment import Moment from flask_babel import Babel, lazy_gettext as _l from config import Config -app = Flask(__name__) -app.config.from_object(Config) -db = SQLAlchemy(app) -migrate = Migrate(app, db) -login = LoginManager(app) -login.login_view = 'login' +db = SQLAlchemy() +migrate = Migrate() +login = LoginManager() +login.login_view = 'auth.login' login.login_message = _l('Please log in to access this page.') -mail = Mail(app) -bootstrap = Bootstrap(app) -moment = Moment(app) -babel = Babel(app) +mail = Mail() +bootstrap = Bootstrap() +moment = Moment() +babel = Babel() -if not app.debug: - if app.config['MAIL_SERVER']: - auth = None - if app.config['MAIL_USERNAME'] or app.config['MAIL_PASSWORD']: - auth = (app.config['MAIL_USERNAME'], app.config['MAIL_PASSWORD']) - secure = None - if app.config['MAIL_USE_TLS']: - secure = () - mail_handler = SMTPHandler( - mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']), - fromaddr='no-reply@' + app.config['MAIL_SERVER'], - toaddrs=app.config['ADMINS'], subject='Microblog Failure', - credentials=auth, secure=secure) - mail_handler.setLevel(logging.ERROR) - app.logger.addHandler(mail_handler) - if not os.path.exists('logs'): - os.mkdir('logs') - file_handler = RotatingFileHandler('logs/microblog.log', maxBytes=10240, - backupCount=10) - file_handler.setFormatter(logging.Formatter( - '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]')) - file_handler.setLevel(logging.INFO) - app.logger.addHandler(file_handler) +def create_app(config_class=Config): + app = Flask(__name__) + app.config.from_object(config_class) - app.logger.setLevel(logging.INFO) - app.logger.info('Microblog startup') + db.init_app(app) + migrate.init_app(app, db) + login.init_app(app) + mail.init_app(app) + bootstrap.init_app(app) + moment.init_app(app) + babel.init_app(app) + + from app.errors import bp as errors_bp + app.register_blueprint(errors_bp) + + from app.auth import bp as auth_bp + app.register_blueprint(auth_bp, url_prefix='/auth') + + from app.main import bp as main_bp + app.register_blueprint(main_bp) + + if not app.debug and not app.testing: + if app.config['MAIL_SERVER']: + auth = None + if app.config['MAIL_USERNAME'] or app.config['MAIL_PASSWORD']: + auth = (app.config['MAIL_USERNAME'], + app.config['MAIL_PASSWORD']) + secure = None + if app.config['MAIL_USE_TLS']: + secure = () + mail_handler = SMTPHandler( + mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']), + fromaddr='no-reply@' + app.config['MAIL_SERVER'], + toaddrs=app.config['ADMINS'], subject='Microblog Failure', + credentials=auth, secure=secure) + mail_handler.setLevel(logging.ERROR) + app.logger.addHandler(mail_handler) + + if not os.path.exists('logs'): + os.mkdir('logs') + file_handler = RotatingFileHandler('logs/microblog.log', + maxBytes=10240, backupCount=10) + file_handler.setFormatter(logging.Formatter( + '%(asctime)s %(levelname)s: %(message)s ' + '[in %(pathname)s:%(lineno)d]')) + file_handler.setLevel(logging.INFO) + app.logger.addHandler(file_handler) + + app.logger.setLevel(logging.INFO) + app.logger.info('Microblog startup') + + return app @babel.localeselector def get_locale(): - return request.accept_languages.best_match(app.config['LANGUAGES']) + return request.accept_languages.best_match(current_app.config['LANGUAGES']) -from app import routes, models, errors +from app import models diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..088b033 --- /dev/null +++ b/app/auth/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('auth', __name__) + +from app.auth import routes diff --git a/app/auth/email.py b/app/auth/email.py new file mode 100644 index 0000000..98755ac --- /dev/null +++ b/app/auth/email.py @@ -0,0 +1,14 @@ +from flask import render_template, current_app +from flask_babel import _ +from app.email import send_email + + +def send_password_reset_email(user): + token = user.get_reset_password_token() + send_email(_('[Microblog] Reset Your Password'), + sender=current_app.config['ADMINS'][0], + recipients=[user.email], + text_body=render_template('email/reset_password.txt', + user=user, token=token), + html_body=render_template('email/reset_password.html', + user=user, token=token)) diff --git a/app/forms.py b/app/auth/forms.py similarity index 67% rename from app/forms.py rename to app/auth/forms.py index 828c257..c1dd3eb 100644 --- a/app/forms.py +++ b/app/auth/forms.py @@ -1,8 +1,6 @@ from flask_wtf import FlaskForm -from wtforms import StringField, PasswordField, BooleanField, SubmitField, \ - TextAreaField -from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, \ - Length +from wtforms import StringField, PasswordField, BooleanField, SubmitField +from wtforms.validators import ValidationError, DataRequired, Email, EqualTo from flask_babel import _, lazy_gettext as _l from app.models import User @@ -45,25 +43,3 @@ class ResetPasswordForm(FlaskForm): _l('Repeat Password'), validators=[DataRequired(), EqualTo('password')]) submit = SubmitField(_l('Request Password Reset')) - - -class EditProfileForm(FlaskForm): - username = StringField(_l('Username'), validators=[DataRequired()]) - about_me = TextAreaField(_l('About me'), - validators=[Length(min=0, max=140)]) - submit = SubmitField(_l('Submit')) - - def __init__(self, original_username, *args, **kwargs): - super(EditProfileForm, self).__init__(*args, **kwargs) - self.original_username = original_username - - def validate_username(self, username): - if username.data != self.original_username: - user = User.query.filter_by(username=self.username.data).first() - if user is not None: - raise ValidationError(_('Please use a different username.')) - - -class PostForm(FlaskForm): - post = TextAreaField(_l('Say something'), validators=[DataRequired()]) - submit = SubmitField(_l('Submit')) diff --git a/app/auth/routes.py b/app/auth/routes.py new file mode 100644 index 0000000..b3f1d72 --- /dev/null +++ b/app/auth/routes.py @@ -0,0 +1,82 @@ +from flask import render_template, redirect, url_for, flash, request +from werkzeug.urls import url_parse +from flask_login import login_user, logout_user, current_user +from flask_babel import _ +from app import db +from app.auth import bp +from app.auth.forms import LoginForm, RegistrationForm, \ + ResetPasswordRequestForm, ResetPasswordForm +from app.models import User +from app.auth.email import send_password_reset_email + + +@bp.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(username=form.username.data).first() + if user is None or not user.check_password(form.password.data): + flash(_('Invalid username or password')) + return redirect(url_for('auth.login')) + login_user(user, remember=form.remember_me.data) + next_page = request.args.get('next') + if not next_page or url_parse(next_page).netloc != '': + next_page = url_for('main.index') + return redirect(next_page) + return render_template('auth/login.html', title=_('Sign In'), form=form) + + +@bp.route('/logout') +def logout(): + logout_user() + return redirect(url_for('main.index')) + + +@bp.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + form = RegistrationForm() + if form.validate_on_submit(): + user = User(username=form.username.data, email=form.email.data) + user.set_password(form.password.data) + db.session.add(user) + db.session.commit() + flash(_('Congratulations, you are now a registered user!')) + return redirect(url_for('auth.login')) + return render_template('auth/register.html', title=_('Register'), + form=form) + + +@bp.route('/reset_password_request', methods=['GET', 'POST']) +def reset_password_request(): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + form = ResetPasswordRequestForm() + if form.validate_on_submit(): + user = User.query.filter_by(email=form.email.data).first() + if user: + send_password_reset_email(user) + flash( + _('Check your email for the instructions to reset your password')) + return redirect(url_for('auth.login')) + return render_template('auth/reset_password_request.html', + title=_('Reset Password'), form=form) + + +@bp.route('/reset_password/', methods=['GET', 'POST']) +def reset_password(token): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + user = User.verify_reset_password_token(token) + if not user: + return redirect(url_for('main.index')) + form = ResetPasswordForm() + if form.validate_on_submit(): + user.set_password(form.password.data) + db.session.commit() + flash(_('Your password has been reset.')) + return redirect(url_for('auth.login')) + return render_template('auth/reset_password.html', form=form) diff --git a/app/cli.py b/app/cli.py index 8c6697a..e9b3d76 100644 --- a/app/cli.py +++ b/app/cli.py @@ -1,38 +1,35 @@ import os import click -from app import app -@app.cli.group() -def translate(): - """Translation and localization commands.""" - pass +def register(app): + @app.cli.group() + def translate(): + """Translation and localization commands.""" + pass + @translate.command() + @click.argument('lang') + def init(lang): + """Initialize a new language.""" + if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'): + raise RuntimeError('extract command failed') + if os.system( + 'pybabel init -i messages.pot -d app/translations -l ' + lang): + raise RuntimeError('init command failed') + os.remove('messages.pot') -@translate.command() -@click.argument('lang') -def init(lang): - """Initialize a new language.""" - if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'): - raise RuntimeError('extract command failed') - if os.system( - 'pybabel init -i messages.pot -d app/translations -l ' + lang): - raise RuntimeError('init command failed') - os.remove('messages.pot') + @translate.command() + def update(): + """Update all languages.""" + if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'): + raise RuntimeError('extract command failed') + if os.system('pybabel update -i messages.pot -d app/translations'): + raise RuntimeError('update command failed') + os.remove('messages.pot') - -@translate.command() -def update(): - """Update all languages.""" - if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'): - raise RuntimeError('extract command failed') - if os.system('pybabel update -i messages.pot -d app/translations'): - raise RuntimeError('update command failed') - os.remove('messages.pot') - - -@translate.command() -def compile(): - """Compile all languages.""" - if os.system('pybabel compile -d app/translations'): - raise RuntimeError('compile command failed') + @translate.command() + def compile(): + """Compile all languages.""" + if os.system('pybabel compile -d app/translations'): + raise RuntimeError('compile command failed') diff --git a/app/email.py b/app/email.py index 1f4d46f..ee23da8 100644 --- a/app/email.py +++ b/app/email.py @@ -1,8 +1,7 @@ from threading import Thread -from flask import render_template +from flask import current_app from flask_mail import Message -from flask_babel import _ -from app import app, mail +from app import mail def send_async_email(app, msg): @@ -14,15 +13,5 @@ def send_email(subject, sender, recipients, text_body, html_body): msg = Message(subject, sender=sender, recipients=recipients) msg.body = text_body msg.html = html_body - Thread(target=send_async_email, args=(app, msg)).start() - - -def send_password_reset_email(user): - token = user.get_reset_password_token() - send_email(_('[Microblog] Reset Your Password'), - sender=app.config['ADMINS'][0], - recipients=[user.email], - text_body=render_template('email/reset_password.txt', - user=user, token=token), - html_body=render_template('email/reset_password.html', - user=user, token=token)) + Thread(target=send_async_email, + args=(current_app._get_current_object(), msg)).start() diff --git a/app/errors.py b/app/errors.py deleted file mode 100644 index ed214c4..0000000 --- a/app/errors.py +++ /dev/null @@ -1,13 +0,0 @@ -from flask import render_template -from app import app, db - - -@app.errorhandler(404) -def not_found_error(error): - return render_template('404.html'), 404 - - -@app.errorhandler(500) -def internal_error(error): - db.session.rollback() - return render_template('500.html'), 500 diff --git a/app/errors/__init__.py b/app/errors/__init__.py new file mode 100644 index 0000000..5701c1d --- /dev/null +++ b/app/errors/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('errors', __name__) + +from app.errors import handlers diff --git a/app/errors/handlers.py b/app/errors/handlers.py new file mode 100644 index 0000000..4a40ad9 --- /dev/null +++ b/app/errors/handlers.py @@ -0,0 +1,14 @@ +from flask import render_template +from app import db +from app.errors import bp + + +@bp.app_errorhandler(404) +def not_found_error(error): + return render_template('errors/404.html'), 404 + + +@bp.app_errorhandler(500) +def internal_error(error): + db.session.rollback() + return render_template('errors/500.html'), 500 diff --git a/app/main/__init__.py b/app/main/__init__.py new file mode 100644 index 0000000..3b580b0 --- /dev/null +++ b/app/main/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('main', __name__) + +from app.main import routes diff --git a/app/main/forms.py b/app/main/forms.py new file mode 100644 index 0000000..a688ba7 --- /dev/null +++ b/app/main/forms.py @@ -0,0 +1,29 @@ +from flask import request +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField, TextAreaField +from wtforms.validators import ValidationError, DataRequired, Length +from flask_babel import _, lazy_gettext as _l +from app.models import User + + +class EditProfileForm(FlaskForm): + username = StringField(_l('Username'), validators=[DataRequired()]) + about_me = TextAreaField(_l('About me'), + validators=[Length(min=0, max=140)]) + submit = SubmitField(_l('Submit')) + + def __init__(self, original_username, *args, **kwargs): + super(EditProfileForm, self).__init__(*args, **kwargs) + self.original_username = original_username + + def validate_username(self, username): + if username.data != self.original_username: + user = User.query.filter_by(username=self.username.data).first() + if user is not None: + raise ValidationError(_('Please use a different username.')) + + +class PostForm(FlaskForm): + post = TextAreaField(_l('Say something'), validators=[DataRequired()]) + submit = SubmitField(_l('Submit')) + diff --git a/app/main/routes.py b/app/main/routes.py new file mode 100644 index 0000000..8a31b20 --- /dev/null +++ b/app/main/routes.py @@ -0,0 +1,134 @@ +from datetime import datetime +from flask import render_template, flash, redirect, url_for, request, g, \ + jsonify, current_app +from flask_login import current_user, login_required +from flask_babel import _, get_locale +from guess_language import guess_language +from app import db +from app.main.forms import EditProfileForm, PostForm +from app.models import User, Post +from app.translate import translate +from app.main import bp + + +@bp.before_app_request +def before_request(): + if current_user.is_authenticated: + current_user.last_seen = datetime.utcnow() + db.session.commit() + g.locale = str(get_locale()) + + +@bp.route('/', methods=['GET', 'POST']) +@bp.route('/index', methods=['GET', 'POST']) +@login_required +def index(): + form = PostForm() + if form.validate_on_submit(): + language = guess_language(form.post.data) + if language == 'UNKNOWN' or len(language) > 5: + language = '' + post = Post(body=form.post.data, author=current_user, + language=language) + db.session.add(post) + db.session.commit() + flash(_('Your post is now live!')) + return redirect(url_for('main.index')) + page = request.args.get('page', 1, type=int) + posts = current_user.followed_posts().paginate( + page, current_app.config['POSTS_PER_PAGE'], False) + next_url = url_for('main.index', page=posts.next_num) \ + if posts.has_next else None + prev_url = url_for('main.index', page=posts.prev_num) \ + if posts.has_prev else None + return render_template('index.html', title=_('Home'), form=form, + posts=posts.items, next_url=next_url, + prev_url=prev_url) + + +@bp.route('/explore') +@login_required +def explore(): + page = request.args.get('page', 1, type=int) + posts = Post.query.order_by(Post.timestamp.desc()).paginate( + page, current_app.config['POSTS_PER_PAGE'], False) + next_url = url_for('main.explore', page=posts.next_num) \ + if posts.has_next else None + prev_url = url_for('main.explore', page=posts.prev_num) \ + if posts.has_prev else None + return render_template('index.html', title=_('Explore'), + posts=posts.items, next_url=next_url, + prev_url=prev_url) + + +@bp.route('/user/') +@login_required +def user(username): + user = User.query.filter_by(username=username).first_or_404() + page = request.args.get('page', 1, type=int) + posts = user.posts.order_by(Post.timestamp.desc()).paginate( + page, current_app.config['POSTS_PER_PAGE'], False) + next_url = url_for('main.user', username=user.username, + page=posts.next_num) if posts.has_next else None + prev_url = url_for('main.user', username=user.username, + page=posts.prev_num) if posts.has_prev else None + return render_template('user.html', user=user, posts=posts.items, + next_url=next_url, prev_url=prev_url) + + +@bp.route('/edit_profile', methods=['GET', 'POST']) +@login_required +def edit_profile(): + form = EditProfileForm(current_user.username) + if form.validate_on_submit(): + current_user.username = form.username.data + current_user.about_me = form.about_me.data + db.session.commit() + flash(_('Your changes have been saved.')) + return redirect(url_for('main.edit_profile')) + elif request.method == 'GET': + form.username.data = current_user.username + form.about_me.data = current_user.about_me + return render_template('edit_profile.html', title=_('Edit Profile'), + form=form) + + +@bp.route('/follow/') +@login_required +def follow(username): + user = User.query.filter_by(username=username).first() + if user is None: + flash(_('User %(username)s not found.', username=username)) + return redirect(url_for('main.index')) + if user == current_user: + flash(_('You cannot follow yourself!')) + return redirect(url_for('main.user', username=username)) + current_user.follow(user) + db.session.commit() + flash(_('You are following %(username)s!', username=username)) + return redirect(url_for('main.user', username=username)) + + +@bp.route('/unfollow/') +@login_required +def unfollow(username): + user = User.query.filter_by(username=username).first() + if user is None: + flash(_('User %(username)s not found.', username=username)) + return redirect(url_for('main.index')) + if user == current_user: + flash(_('You cannot unfollow yourself!')) + return redirect(url_for('main.user', username=username)) + current_user.unfollow(user) + db.session.commit() + flash(_('You are not following %(username)s.', username=username)) + return redirect(url_for('main.user', username=username)) + + +@bp.route('/translate', methods=['POST']) +@login_required +def translate_text(): + return jsonify({'text': translate(request.form['text'], + request.form['source_language'], + request.form['dest_language'])}) + diff --git a/app/models.py b/app/models.py index bf6613a..70ad4a1 100644 --- a/app/models.py +++ b/app/models.py @@ -1,10 +1,11 @@ from datetime import datetime from hashlib import md5 from time import time +from flask import current_app from flask_login import UserMixin from werkzeug.security import generate_password_hash, check_password_hash import jwt -from app import app, db, login +from app import db, login followers = db.Table( @@ -64,12 +65,13 @@ class User(UserMixin, db.Model): def get_reset_password_token(self, expires_in=600): return jwt.encode( {'reset_password': self.id, 'exp': time() + expires_in}, - app.config['SECRET_KEY'], algorithm='HS256').decode('utf-8') + current_app.config['SECRET_KEY'], + algorithm='HS256').decode('utf-8') @staticmethod def verify_reset_password_token(token): try: - id = jwt.decode(token, app.config['SECRET_KEY'], + id = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])['reset_password'] except: return diff --git a/app/routes.py b/app/routes.py deleted file mode 100644 index 529fb7b..0000000 --- a/app/routes.py +++ /dev/null @@ -1,206 +0,0 @@ -from datetime import datetime -from flask import render_template, flash, redirect, url_for, request, g, \ - jsonify -from flask_login import login_user, logout_user, current_user, login_required -from werkzeug.urls import url_parse -from flask_babel import _, get_locale -from guess_language import guess_language -from app import app, db -from app.forms import LoginForm, RegistrationForm, EditProfileForm, PostForm, \ - ResetPasswordRequestForm, ResetPasswordForm -from app.models import User, Post -from app.email import send_password_reset_email -from app.translate import translate - - -@app.before_request -def before_request(): - if current_user.is_authenticated: - current_user.last_seen = datetime.utcnow() - db.session.commit() - g.locale = str(get_locale()) - - -@app.route('/', methods=['GET', 'POST']) -@app.route('/index', methods=['GET', 'POST']) -@login_required -def index(): - form = PostForm() - if form.validate_on_submit(): - language = guess_language(form.post.data) - if language == 'UNKNOWN' or len(language) > 5: - language = '' - post = Post(body=form.post.data, author=current_user, - language=language) - db.session.add(post) - db.session.commit() - flash(_('Your post is now live!')) - return redirect(url_for('index')) - page = request.args.get('page', 1, type=int) - posts = current_user.followed_posts().paginate( - page, app.config['POSTS_PER_PAGE'], False) - next_url = url_for('index', page=posts.next_num) \ - if posts.has_next else None - prev_url = url_for('index', page=posts.prev_num) \ - if posts.has_prev else None - return render_template('index.html', title=_('Home'), form=form, - posts=posts.items, next_url=next_url, - prev_url=prev_url) - - -@app.route('/explore') -@login_required -def explore(): - page = request.args.get('page', 1, type=int) - posts = Post.query.order_by(Post.timestamp.desc()).paginate( - page, app.config['POSTS_PER_PAGE'], False) - next_url = url_for('explore', page=posts.next_num) \ - if posts.has_next else None - prev_url = url_for('explore', page=posts.prev_num) \ - if posts.has_prev else None - return render_template('index.html', title=_('Explore'), - posts=posts.items, next_url=next_url, - prev_url=prev_url) - - -@app.route('/login', methods=['GET', 'POST']) -def login(): - if current_user.is_authenticated: - return redirect(url_for('index')) - form = LoginForm() - if form.validate_on_submit(): - user = User.query.filter_by(username=form.username.data).first() - if user is None or not user.check_password(form.password.data): - flash(_('Invalid username or password')) - return redirect(url_for('login')) - login_user(user, remember=form.remember_me.data) - next_page = request.args.get('next') - if not next_page or url_parse(next_page).netloc != '': - next_page = url_for('index') - return redirect(next_page) - return render_template('login.html', title=_('Sign In'), form=form) - - -@app.route('/logout') -def logout(): - logout_user() - return redirect(url_for('index')) - - -@app.route('/register', methods=['GET', 'POST']) -def register(): - if current_user.is_authenticated: - return redirect(url_for('index')) - form = RegistrationForm() - if form.validate_on_submit(): - user = User(username=form.username.data, email=form.email.data) - user.set_password(form.password.data) - db.session.add(user) - db.session.commit() - flash(_('Congratulations, you are now a registered user!')) - return redirect(url_for('login')) - return render_template('register.html', title=_('Register'), form=form) - - -@app.route('/reset_password_request', methods=['GET', 'POST']) -def reset_password_request(): - if current_user.is_authenticated: - return redirect(url_for('index')) - form = ResetPasswordRequestForm() - if form.validate_on_submit(): - user = User.query.filter_by(email=form.email.data).first() - if user: - send_password_reset_email(user) - flash( - _('Check your email for the instructions to reset your password')) - return redirect(url_for('login')) - return render_template('reset_password_request.html', - title=_('Reset Password'), form=form) - - -@app.route('/reset_password/', methods=['GET', 'POST']) -def reset_password(token): - if current_user.is_authenticated: - return redirect(url_for('index')) - user = User.verify_reset_password_token(token) - if not user: - return redirect(url_for('index')) - form = ResetPasswordForm() - if form.validate_on_submit(): - user.set_password(form.password.data) - db.session.commit() - flash(_('Your password has been reset.')) - return redirect(url_for('login')) - return render_template('reset_password.html', form=form) - - -@app.route('/user/') -@login_required -def user(username): - user = User.query.filter_by(username=username).first_or_404() - page = request.args.get('page', 1, type=int) - posts = user.posts.order_by(Post.timestamp.desc()).paginate( - page, app.config['POSTS_PER_PAGE'], False) - next_url = url_for('user', username=user.username, page=posts.next_num) \ - if posts.has_next else None - prev_url = url_for('user', username=user.username, page=posts.prev_num) \ - if posts.has_prev else None - return render_template('user.html', user=user, posts=posts.items, - next_url=next_url, prev_url=prev_url) - - -@app.route('/edit_profile', methods=['GET', 'POST']) -@login_required -def edit_profile(): - form = EditProfileForm(current_user.username) - if form.validate_on_submit(): - current_user.username = form.username.data - current_user.about_me = form.about_me.data - db.session.commit() - flash(_('Your changes have been saved.')) - return redirect(url_for('edit_profile')) - elif request.method == 'GET': - form.username.data = current_user.username - form.about_me.data = current_user.about_me - return render_template('edit_profile.html', title=_('Edit Profile'), - form=form) - - -@app.route('/follow/') -@login_required -def follow(username): - user = User.query.filter_by(username=username).first() - if user is None: - flash(_('User %(username)s not found.', username=username)) - return redirect(url_for('index')) - if user == current_user: - flash(_('You cannot follow yourself!')) - return redirect(url_for('user', username=username)) - current_user.follow(user) - db.session.commit() - flash(_('You are following %(username)s!', username=username)) - return redirect(url_for('user', username=username)) - - -@app.route('/unfollow/') -@login_required -def unfollow(username): - user = User.query.filter_by(username=username).first() - if user is None: - flash(_('User %(username)s not found.', username=username)) - return redirect(url_for('index')) - if user == current_user: - flash(_('You cannot unfollow yourself!')) - return redirect(url_for('user', username=username)) - current_user.unfollow(user) - db.session.commit() - flash(_('You are not following %(username)s.', username=username)) - return redirect(url_for('user', username=username)) - - -@app.route('/translate', methods=['POST']) -@login_required -def translate_text(): - return jsonify({'text': translate(request.form['text'], - request.form['source_language'], - request.form['dest_language'])}) diff --git a/app/templates/_post.html b/app/templates/_post.html index 1c13800..895fd47 100644 --- a/app/templates/_post.html +++ b/app/templates/_post.html @@ -1,13 +1,13 @@ diff --git a/app/translate.py b/app/translate.py index be1411e..ec54802 100644 --- a/app/translate.py +++ b/app/translate.py @@ -1,14 +1,15 @@ import json import requests +from flask import current_app from flask_babel import _ -from app import app def translate(text, source_language, dest_language): - if 'MS_TRANSLATOR_KEY' not in app.config or \ - not app.config['MS_TRANSLATOR_KEY']: + if 'MS_TRANSLATOR_KEY' not in current_app.config or \ + not current_app.config['MS_TRANSLATOR_KEY']: return _('Error: the translation service is not configured.') - auth = {'Ocp-Apim-Subscription-Key': app.config['MS_TRANSLATOR_KEY']} + auth = { + 'Ocp-Apim-Subscription-Key': current_app.config['MS_TRANSLATOR_KEY']} r = requests.get('https://api.microsofttranslator.com/v2/Ajax.svc' '/Translate?text={}&from={}&to={}'.format( text, source_language, dest_language), diff --git a/app/translations/es/LC_MESSAGES/messages.po b/app/translations/es/LC_MESSAGES/messages.po index d468175..e21f644 100644 --- a/app/translations/es/LC_MESSAGES/messages.po +++ b/app/translations/es/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2017-10-05 15:32-0700\n" +"POT-Creation-Date: 2017-11-25 17:17-0800\n" "PO-Revision-Date: 2017-09-29 23:25-0700\n" "Last-Translator: FULL NAME \n" "Language: es\n" @@ -18,151 +18,131 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.5.1\n" -#: app/__init__.py:20 +#: app/__init__.py:17 msgid "Please log in to access this page." msgstr "Por favor ingrese para acceder a esta página." -#: app/email.py:21 -msgid "[Microblog] Reset Your Password" -msgstr "[Microblog] Nueva Contraseña" - -#: app/forms.py:12 app/forms.py:19 app/forms.py:50 -msgid "Username" -msgstr "Nombre de usuario" - -#: app/forms.py:13 app/forms.py:21 app/forms.py:43 -msgid "Password" -msgstr "Contraseña" - -#: app/forms.py:14 -msgid "Remember Me" -msgstr "Recordarme" - -#: app/forms.py:15 app/templates/login.html:5 -msgid "Sign In" -msgstr "Ingresar" - -#: app/forms.py:20 app/forms.py:38 -msgid "Email" -msgstr "Email" - -#: app/forms.py:23 app/forms.py:45 -msgid "Repeat Password" -msgstr "Repetir Contraseña" - -#: app/forms.py:24 app/templates/register.html:5 -msgid "Register" -msgstr "Registrarse" - -#: app/forms.py:29 app/forms.py:62 -msgid "Please use a different username." -msgstr "Por favor use un nombre de usuario diferente." - -#: app/forms.py:34 -msgid "Please use a different email address." -msgstr "Por favor use una dirección de email diferente." - -#: app/forms.py:39 app/forms.py:46 -msgid "Request Password Reset" -msgstr "Pedir una nueva contraseña" - -#: app/forms.py:51 -msgid "About me" -msgstr "Acerca de mí" - -#: app/forms.py:52 app/forms.py:67 -msgid "Submit" -msgstr "Enviar" - -#: app/forms.py:66 -msgid "Say something" -msgstr "Dí algo" - -#: app/forms.py:71 -msgid "Search" -msgstr "Buscar" - -#: app/routes.py:37 -msgid "Your post is now live!" -msgstr "¡Tu artículo ha sido publicado!" - -#: app/routes.py:73 -msgid "Invalid username or password" -msgstr "Nombre de usuario o contraseña inválidos" - -#: app/routes.py:99 -msgid "Congratulations, you are now a registered user!" -msgstr "¡Felicitaciones, ya eres un usuario registrado!" - -#: app/routes.py:114 -msgid "Check your email for the instructions to reset your password" -msgstr "Busca en tu email las instrucciones para crear una nueva contraseña" - -#: app/routes.py:131 -msgid "Your password has been reset." -msgstr "Tu contraseña ha sido cambiada." - -#: app/routes.py:159 -msgid "Your changes have been saved." -msgstr "Tus cambios han sido salvados." - -#: app/routes.py:164 app/templates/edit_profile.html:5 -msgid "Edit Profile" -msgstr "Editar Perfil" - -#: app/routes.py:173 app/routes.py:189 -#, python-format -msgid "User %(username)s not found." -msgstr "El usuario %(username)s no ha sido encontrado." - -#: app/routes.py:176 -msgid "You cannot follow yourself!" -msgstr "¡No te puedes seguir a tí mismo!" - -#: app/routes.py:180 -#, python-format -msgid "You are following %(username)s!" -msgstr "¡Ahora estás siguiendo a %(username)s!" - -#: app/routes.py:192 -msgid "You cannot unfollow yourself!" -msgstr "¡No te puedes dejar de seguir a tí mismo!" - -#: app/routes.py:196 -#, python-format -msgid "You are not following %(username)s." -msgstr "No estás siguiendo a %(username)s." - #: app/translate.py:10 msgid "Error: the translation service is not configured." msgstr "Error: el servicio de traducciones no está configurado." -#: app/translate.py:17 +#: app/translate.py:18 msgid "Error: the translation service failed." msgstr "Error el servicio de traducciones ha fallado." -#: app/templates/404.html:4 -msgid "Not Found" -msgstr "Página No Encontrada" +#: app/auth/email.py:8 +msgid "[Microblog] Reset Your Password" +msgstr "[Microblog] Nueva Contraseña" -#: app/templates/404.html:5 app/templates/500.html:6 -msgid "Back" -msgstr "Atrás" +#: app/auth/forms.py:9 app/auth/forms.py:16 app/main/forms.py:10 +msgid "Username" +msgstr "Nombre de usuario" -#: app/templates/500.html:4 -msgid "An unexpected error has occurred" -msgstr "Ha ocurrido un error inesperado" +#: app/auth/forms.py:10 app/auth/forms.py:18 app/auth/forms.py:41 +msgid "Password" +msgstr "Contraseña" -#: app/templates/500.html:5 -msgid "The administrator has been notified. Sorry for the inconvenience!" -msgstr "El administrador ha sido notificado. ¡Lamentamos la inconveniencia!" +#: app/auth/forms.py:11 +msgid "Remember Me" +msgstr "Recordarme" -#: app/templates/_post.html:9 +#: app/auth/forms.py:12 app/templates/auth/login.html:5 +msgid "Sign In" +msgstr "Ingresar" + +#: app/auth/forms.py:17 app/auth/forms.py:36 +msgid "Email" +msgstr "Email" + +#: app/auth/forms.py:20 app/auth/forms.py:43 +msgid "Repeat Password" +msgstr "Repetir Contraseña" + +#: app/auth/forms.py:22 app/templates/auth/register.html:5 +msgid "Register" +msgstr "Registrarse" + +#: app/auth/forms.py:27 app/main/forms.py:23 +msgid "Please use a different username." +msgstr "Por favor use un nombre de usuario diferente." + +#: app/auth/forms.py:32 +msgid "Please use a different email address." +msgstr "Por favor use una dirección de email diferente." + +#: app/auth/forms.py:37 app/auth/forms.py:45 +msgid "Request Password Reset" +msgstr "Pedir una nueva contraseña" + +#: app/auth/routes.py:20 +msgid "Invalid username or password" +msgstr "Nombre de usuario o contraseña inválidos" + +#: app/auth/routes.py:46 +msgid "Congratulations, you are now a registered user!" +msgstr "¡Felicitaciones, ya eres un usuario registrado!" + +#: app/auth/routes.py:61 +msgid "Check your email for the instructions to reset your password" +msgstr "Busca en tu email las instrucciones para crear una nueva contraseña" + +#: app/auth/routes.py:78 +msgid "Your password has been reset." +msgstr "Tu contraseña ha sido cambiada." + +#: app/main/forms.py:11 +msgid "About me" +msgstr "Acerca de mí" + +#: app/main/forms.py:13 app/main/forms.py:28 +msgid "Submit" +msgstr "Enviar" + +#: app/main/forms.py:27 +msgid "Say something" +msgstr "Dí algo" + +#: app/main/routes.py:35 +msgid "Your post is now live!" +msgstr "¡Tu artículo ha sido publicado!" + +#: app/main/routes.py:86 +msgid "Your changes have been saved." +msgstr "Tus cambios han sido salvados." + +#: app/main/routes.py:91 app/templates/edit_profile.html:5 +msgid "Edit Profile" +msgstr "Editar Perfil" + +#: app/main/routes.py:100 app/main/routes.py:116 +#, python-format +msgid "User %(username)s not found." +msgstr "El usuario %(username)s no ha sido encontrado." + +#: app/main/routes.py:103 +msgid "You cannot follow yourself!" +msgstr "¡No te puedes seguir a tí mismo!" + +#: app/main/routes.py:107 +#, python-format +msgid "You are following %(username)s!" +msgstr "¡Ahora estás siguiendo a %(username)s!" + +#: app/main/routes.py:119 +msgid "You cannot unfollow yourself!" +msgstr "¡No te puedes dejar de seguir a tí mismo!" + +#: app/main/routes.py:123 +#, python-format +msgid "You are not following %(username)s." +msgstr "No estás siguiendo a %(username)s." + +#: app/templates/_post.html:14 #, python-format msgid "%(username)s said %(when)s" msgstr "%(username)s dijo %(when)s" -#: app/templates/_post.html:19 +#: app/templates/_post.html:25 msgid "Translate" msgstr "Traducir" @@ -178,19 +158,19 @@ msgstr "Inicio" msgid "Explore" msgstr "Explorar" -#: app/templates/base.html:33 +#: app/templates/base.html:26 msgid "Login" msgstr "Ingresar" -#: app/templates/base.html:35 +#: app/templates/base.html:28 msgid "Profile" msgstr "Perfil" -#: app/templates/base.html:36 +#: app/templates/base.html:29 msgid "Logout" msgstr "Salir" -#: app/templates/base.html:73 +#: app/templates/base.html:66 msgid "Error: Could not contact server." msgstr "Error: el servidor no pudo ser contactado." @@ -207,42 +187,6 @@ msgstr "Artículos siguientes" msgid "Older posts" msgstr "Artículos previos" -#: app/templates/login.html:12 -msgid "New User?" -msgstr "¿Usuario Nuevo?" - -#: app/templates/login.html:12 -msgid "Click to Register!" -msgstr "¡Haz click aquí para registrarte!" - -#: app/templates/login.html:14 -msgid "Forgot Your Password?" -msgstr "¿Te olvidaste tu contraseña?" - -#: app/templates/login.html:15 -msgid "Click to Reset It" -msgstr "Haz click aquí para pedir una nueva" - -#: app/templates/reset_password.html:5 -msgid "Reset Your Password" -msgstr "Nueva Contraseña" - -#: app/templates/reset_password_request.html:5 -msgid "Reset Password" -msgstr "Nueva Contraseña" - -#: app/templates/search.html:4 -msgid "Search Results" -msgstr "Resultados de Búsqueda" - -#: app/templates/search.html:12 -msgid "Previous results" -msgstr "Resultados previos" - -#: app/templates/search.html:17 -msgid "Next results" -msgstr "Resultados próximos" - #: app/templates/user.html:8 msgid "User" msgstr "Usuario" @@ -273,3 +217,42 @@ msgstr "Seguir" msgid "Unfollow" msgstr "Dejar de seguir" +#: app/templates/auth/login.html:12 +msgid "New User?" +msgstr "¿Usuario Nuevo?" + +#: app/templates/auth/login.html:12 +msgid "Click to Register!" +msgstr "¡Haz click aquí para registrarte!" + +#: app/templates/auth/login.html:14 +msgid "Forgot Your Password?" +msgstr "¿Te olvidaste tu contraseña?" + +#: app/templates/auth/login.html:15 +msgid "Click to Reset It" +msgstr "Haz click aquí para pedir una nueva" + +#: app/templates/auth/reset_password.html:5 +msgid "Reset Your Password" +msgstr "Nueva Contraseña" + +#: app/templates/auth/reset_password_request.html:5 +msgid "Reset Password" +msgstr "Nueva Contraseña" + +#: app/templates/errors/404.html:4 +msgid "Not Found" +msgstr "Página No Encontrada" + +#: app/templates/errors/404.html:5 app/templates/errors/500.html:6 +msgid "Back" +msgstr "Atrás" + +#: app/templates/errors/500.html:4 +msgid "An unexpected error has occurred" +msgstr "Ha ocurrido un error inesperado" + +#: app/templates/errors/500.html:5 +msgid "The administrator has been notified. Sorry for the inconvenience!" +msgstr "El administrador ha sido notificado. ¡Lamentamos la inconveniencia!" diff --git a/config.py b/config.py index 880701f..cbb7c2c 100644 --- a/config.py +++ b/config.py @@ -1,5 +1,8 @@ import os +from dotenv import load_dotenv + basedir = os.path.abspath(os.path.dirname(__file__)) +load_dotenv(os.path.join(basedir, '.env')) class Config(object): diff --git a/microblog.py b/microblog.py index 637d9a3..20d62e6 100644 --- a/microblog.py +++ b/microblog.py @@ -1,6 +1,9 @@ -from app import app, db, cli +from app import create_app, db, cli from app.models import User, Post +app = create_app() +cli.register(app) + @app.shell_context_processor def make_shell_context(): diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7c03d7e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,34 @@ +alembic==0.9.6 +Babel==2.5.1 +blinker==1.4 +certifi==2017.7.27.1 +chardet==3.0.4 +click==6.7 +dominate==2.3.1 +Flask==1.0.2 +Flask-Babel==0.11.2 +Flask-Bootstrap==3.3.7.1 +Flask-Login==0.4.0 +Flask-Mail==0.9.1 +Flask-Migrate==2.1.1 +Flask-Moment==0.5.2 +Flask-SQLAlchemy==2.3.2 +Flask-WTF==0.14.2 +guess_language-spirit==0.5.3 +idna==2.6 +itsdangerous==0.24 +Jinja2==2.10 +Mako==1.0.7 +MarkupSafe==1.0 +PyJWT==1.5.3 +python-dateutil==2.6.1 +python-dotenv==0.7.1 +python-editor==1.0.3 +pytz==2017.2 +requests==2.18.4 +six==1.11.0 +SQLAlchemy==1.1.14 +urllib3==1.22 +visitor==0.1.3 +Werkzeug==0.14.1 +WTForms==2.1 diff --git a/tests.py b/tests.py index 1756dd4..a890e69 100755 --- a/tests.py +++ b/tests.py @@ -1,18 +1,27 @@ #!/usr/bin/env python from datetime import datetime, timedelta import unittest -from app import app, db +from app import create_app, db from app.models import User, Post +from config import Config + + +class TestConfig(Config): + TESTING = True + SQLALCHEMY_DATABASE_URI = 'sqlite://' class UserModelCase(unittest.TestCase): def setUp(self): - app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' + self.app = create_app(TestConfig) + 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_password_hashing(self): u = User(username='susan') From 25a2c47733db184cc3aa47a5acaac84fb97cc1dd Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Wed, 20 Sep 2017 12:51:53 -0700 Subject: [PATCH 2/9] Chapter 16: Full-Text Search (v0.16) --- app/__init__.py | 3 + app/main/forms.py | 10 ++++ app/main/routes.py | 18 +++++- app/models.py | 47 ++++++++++++++- app/search.py | 28 +++++++++ app/templates/base.html | 7 +++ app/templates/search.html | 22 +++++++ app/translations/es/LC_MESSAGES/messages.po | 65 +++++++++++++-------- config.py | 1 + requirements.txt | 1 + tests.py | 1 + 11 files changed, 177 insertions(+), 26 deletions(-) create mode 100644 app/search.py create mode 100644 app/templates/search.html diff --git a/app/__init__.py b/app/__init__.py index e92cd70..a1b9e2b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -9,6 +9,7 @@ from flask_mail import Mail from flask_bootstrap import Bootstrap from flask_moment import Moment from flask_babel import Babel, lazy_gettext as _l +from elasticsearch import Elasticsearch from config import Config db = SQLAlchemy() @@ -33,6 +34,8 @@ def create_app(config_class=Config): bootstrap.init_app(app) moment.init_app(app) babel.init_app(app) + app.elasticsearch = Elasticsearch([app.config['ELASTICSEARCH_URL']]) \ + if app.config['ELASTICSEARCH_URL'] else None from app.errors import bp as errors_bp app.register_blueprint(errors_bp) diff --git a/app/main/forms.py b/app/main/forms.py index a688ba7..cb587db 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -27,3 +27,13 @@ class PostForm(FlaskForm): post = TextAreaField(_l('Say something'), validators=[DataRequired()]) submit = SubmitField(_l('Submit')) + +class SearchForm(FlaskForm): + q = StringField(_l('Search'), validators=[DataRequired()]) + + def __init__(self, *args, **kwargs): + if 'formdata' not in kwargs: + kwargs['formdata'] = request.args + if 'csrf_enabled' not in kwargs: + kwargs['csrf_enabled'] = False + super(SearchForm, self).__init__(*args, **kwargs) diff --git a/app/main/routes.py b/app/main/routes.py index 8a31b20..ec4a07b 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -5,7 +5,7 @@ from flask_login import current_user, login_required from flask_babel import _, get_locale from guess_language import guess_language from app import db -from app.main.forms import EditProfileForm, PostForm +from app.main.forms import EditProfileForm, PostForm, SearchForm from app.models import User, Post from app.translate import translate from app.main import bp @@ -16,6 +16,7 @@ def before_request(): if current_user.is_authenticated: current_user.last_seen = datetime.utcnow() db.session.commit() + g.search_form = SearchForm() g.locale = str(get_locale()) @@ -132,3 +133,18 @@ def translate_text(): request.form['source_language'], request.form['dest_language'])}) + +@bp.route('/search') +@login_required +def search(): + if not g.search_form.validate(): + return redirect(url_for('main.explore')) + page = request.args.get('page', 1, type=int) + posts, total = Post.search(g.search_form.q.data, page, + current_app.config['POSTS_PER_PAGE']) + next_url = url_for('main.search', q=g.search_form.q.data, page=page + 1) \ + if total > page * current_app.config['POSTS_PER_PAGE'] else None + prev_url = url_for('main.search', q=g.search_form.q.data, page=page - 1) \ + if page > 1 else None + return render_template('search.html', title=_('Search'), posts=posts, + next_url=next_url, prev_url=prev_url) diff --git a/app/models.py b/app/models.py index 70ad4a1..ecd6b20 100644 --- a/app/models.py +++ b/app/models.py @@ -6,6 +6,50 @@ from flask_login import UserMixin from werkzeug.security import generate_password_hash, check_password_hash import jwt from app import db, login +from app.search import add_to_index, remove_from_index, query_index + + +class SearchableMixin(object): + @classmethod + def search(cls, expression, page, per_page): + ids, total = query_index(cls.__tablename__, expression, page, per_page) + if total == 0: + return cls.query.filter_by(id=0), 0 + when = [] + for i in range(len(ids)): + when.append((ids[i], i)) + return cls.query.filter(cls.id.in_(ids)).order_by( + db.case(when, value=cls.id)), total + + @classmethod + def before_commit(cls, session): + session._changes = { + 'add': list(session.new), + 'update': list(session.dirty), + 'delete': list(session.deleted) + } + + @classmethod + def after_commit(cls, session): + for obj in session._changes['add']: + if isinstance(obj, SearchableMixin): + add_to_index(obj.__tablename__, obj) + for obj in session._changes['update']: + if isinstance(obj, SearchableMixin): + add_to_index(obj.__tablename__, obj) + for obj in session._changes['delete']: + if isinstance(obj, SearchableMixin): + remove_from_index(obj.__tablename__, obj) + session._changes = None + + @classmethod + def reindex(cls): + for obj in cls.query: + add_to_index(cls.__tablename__, obj) + + +db.event.listen(db.session, 'before_commit', SearchableMixin.before_commit) +db.event.listen(db.session, 'after_commit', SearchableMixin.after_commit) followers = db.Table( @@ -83,7 +127,8 @@ def load_user(id): return User.query.get(int(id)) -class Post(db.Model): +class Post(SearchableMixin, db.Model): + __searchable__ = ['body'] id = db.Column(db.Integer, primary_key=True) body = db.Column(db.String(140)) timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow) diff --git a/app/search.py b/app/search.py new file mode 100644 index 0000000..3b939fd --- /dev/null +++ b/app/search.py @@ -0,0 +1,28 @@ +from flask import current_app + + +def add_to_index(index, model): + if not current_app.elasticsearch: + return + payload = {} + for field in model.__searchable__: + payload[field] = getattr(model, field) + current_app.elasticsearch.index(index=index, doc_type=index, id=model.id, + body=payload) + + +def remove_from_index(index, model): + if not current_app.elasticsearch: + return + current_app.elasticsearch.delete(index=index, doc_type=index, id=model.id) + + +def query_index(index, query, page, per_page): + if not current_app.elasticsearch: + return [], 0 + search = current_app.elasticsearch.search( + index=index, doc_type=index, + body={'query': {'multi_match': {'query': query, 'fields': ['*']}}, + 'from': (page - 1) * per_page, 'size': per_page}) + ids = [int(hit['_id']) for hit in search['hits']['hits']] + return ids, search['hits']['total'] diff --git a/app/templates/base.html b/app/templates/base.html index 6a54732..a985a70 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -21,6 +21,13 @@
  • {{ _('Home') }}
  • {{ _('Explore') }}
  • + {% if g.search_form %} + +
    + {{ g.search_form.q(size=20, class='form-control', placeholder=g.search_form.q.label.text) }} +
    + + {% endif %}
    - + {% set user_link %} - + {{ post.author.username }} {% endset %} diff --git a/app/templates/login.html b/app/templates/auth/login.html similarity index 61% rename from app/templates/login.html rename to app/templates/auth/login.html index 56b4c03..fe46cce 100644 --- a/app/templates/login.html +++ b/app/templates/auth/login.html @@ -9,9 +9,9 @@
    -

    {{ _('New User?') }} {{ _('Click to Register!') }}

    +

    {{ _('New User?') }} {{ _('Click to Register!') }}

    {{ _('Forgot Your Password?') }} - {{ _('Click to Reset It') }} + {{ _('Click to Reset It') }}

    {% endblock %} diff --git a/app/templates/register.html b/app/templates/auth/register.html similarity index 100% rename from app/templates/register.html rename to app/templates/auth/register.html diff --git a/app/templates/reset_password.html b/app/templates/auth/reset_password.html similarity index 100% rename from app/templates/reset_password.html rename to app/templates/auth/reset_password.html diff --git a/app/templates/reset_password_request.html b/app/templates/auth/reset_password_request.html similarity index 100% rename from app/templates/reset_password_request.html rename to app/templates/auth/reset_password_request.html diff --git a/app/templates/base.html b/app/templates/base.html index 9874556..6a54732 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -14,19 +14,19 @@ - Microblog + Microblog diff --git a/app/templates/email/reset_password.html b/app/templates/email/reset_password.html index a39a9c4..b60907a 100644 --- a/app/templates/email/reset_password.html +++ b/app/templates/email/reset_password.html @@ -1,12 +1,12 @@

    Dear {{ user.username }},

    To reset your password - + click here .

    Alternatively, you can paste the following link in your browser's address bar:

    -

    {{ url_for('reset_password', token=token, _external=True) }}

    +

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

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

    Sincerely,

    The Microblog Team

    diff --git a/app/templates/email/reset_password.txt b/app/templates/email/reset_password.txt index 5387347..bd107b5 100644 --- a/app/templates/email/reset_password.txt +++ b/app/templates/email/reset_password.txt @@ -2,7 +2,7 @@ Dear {{ user.username }}, To reset your password click on the following link: -{{ url_for('reset_password', token=token, _external=True) }} +{{ url_for('auth.reset_password', token=token, _external=True) }} If you have not requested a password reset simply ignore this message. diff --git a/app/templates/404.html b/app/templates/errors/404.html similarity index 59% rename from app/templates/404.html rename to app/templates/errors/404.html index adc4da2..9cb8d5e 100644 --- a/app/templates/404.html +++ b/app/templates/errors/404.html @@ -2,5 +2,5 @@ {% block app_content %}

    {{ _('Not Found') }}

    -

    {{ _('Back') }}

    +

    {{ _('Back') }}

    {% endblock %} diff --git a/app/templates/500.html b/app/templates/errors/500.html similarity index 75% rename from app/templates/500.html rename to app/templates/errors/500.html index 5975fde..a178ac8 100644 --- a/app/templates/500.html +++ b/app/templates/errors/500.html @@ -3,5 +3,5 @@ {% block app_content %}

    {{ _('An unexpected error has occurred') }}

    {{ _('The administrator has been notified. Sorry for the inconvenience!') }}

    -

    {{ _('Back') }}

    +

    {{ _('Back') }}

    {% endblock %} diff --git a/app/templates/user.html b/app/templates/user.html index e7653e5..1478be8 100644 --- a/app/templates/user.html +++ b/app/templates/user.html @@ -12,11 +12,11 @@ {% endif %}

    {{ _('%(count)d followers', count=user.followers.count()) }}, {{ _('%(count)d following', count=user.followed.count()) }}

    {% if user == current_user %} -

    {{ _('Edit your profile') }}

    +

    {{ _('Edit your profile') }}

    {% elif not current_user.is_following(user) %} -

    {{ _('Follow') }}

    +

    {{ _('Follow') }}

    {% else %} -

    {{ _('Unfollow') }}

    +

    {{ _('Unfollow') }}

    {% endif %}
    {% set user_link %} - - {{ post.author.username }} - + + + {{ post.author.username }} + + {% endset %} {{ _('%(username)s said %(when)s', username=user_link, when=moment(post.timestamp).fromNow()) }} diff --git a/app/templates/base.html b/app/templates/base.html index a985a70..b5f970f 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -73,5 +73,47 @@ $(destElem).text("{{ _('Error: Could not contact server.') }}"); }); } + $(function () { + var timer = null; + var xhr = null; + $('.user_popup').hover( + function(event) { + // mouse in event handler + var elem = $(event.currentTarget); + timer = setTimeout(function() { + timer = null; + xhr = $.ajax( + '/user/' + elem.first().text().trim() + '/popup').done( + function(data) { + xhr = null; + elem.popover({ + trigger: 'manual', + html: true, + animation: false, + container: elem, + content: data + }).popover('show'); + flask_moment_render_all(); + } + ); + }, 1000); + }, + function(event) { + // mouse out event handler + var elem = $(event.currentTarget); + if (timer) { + clearTimeout(timer); + timer = null; + } + else if (xhr) { + xhr.abort(); + xhr = null; + } + else { + elem.popover('destroy'); + } + } + ); + }); {% endblock %} diff --git a/app/templates/user_popup.html b/app/templates/user_popup.html new file mode 100644 index 0000000..1dfb971 --- /dev/null +++ b/app/templates/user_popup.html @@ -0,0 +1,22 @@ + + + + + +
    +

    {{ user.username }}

    + + {% if user.about_me %}

    {{ user.about_me }}

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

    {{ _('Last seen on') }}: {{ moment(user.last_seen).format('lll') }}

    + {% endif %} +

    {{ _('%(count)d followers', count=user.followers.count()) }}, {{ _('%(count)d following', count=user.followed.count()) }}

    + {% if user != current_user %} + {% if not current_user.is_following(user) %} + {{ _('Follow') }} + {% else %} + {{ _('Unfollow') }} + {% endif %} + {% endif %} +
    +
    From 8de4bdb9366501837aaca981278ea6545ecc828f Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Sun, 12 Nov 2017 23:53:18 -0800 Subject: [PATCH 7/9] Chapter 21: User Notifications (v0.21) --- app/main/forms.py | 6 ++ app/main/routes.py | 52 +++++++++++- app/models.py | 43 ++++++++++ app/templates/base.html | 30 +++++++ app/templates/messages.html | 22 +++++ app/templates/send_message.html | 11 +++ app/templates/user.html | 3 + app/translations/es/LC_MESSAGES/messages.po | 81 +++++++++++++------ microblog.py | 5 +- .../versions/d049de007ccf_private_messages.py | 41 ++++++++++ .../versions/f7ac3d27bb1d_notifications.py | 40 +++++++++ 11 files changed, 306 insertions(+), 28 deletions(-) create mode 100644 app/templates/messages.html create mode 100644 app/templates/send_message.html create mode 100644 migrations/versions/d049de007ccf_private_messages.py create mode 100644 migrations/versions/f7ac3d27bb1d_notifications.py diff --git a/app/main/forms.py b/app/main/forms.py index cb587db..bf865e4 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -37,3 +37,9 @@ class SearchForm(FlaskForm): if 'csrf_enabled' not in kwargs: kwargs['csrf_enabled'] = False super(SearchForm, self).__init__(*args, **kwargs) + + +class MessageForm(FlaskForm): + message = TextAreaField(_l('Message'), validators=[ + DataRequired(), Length(min=1, max=140)]) + submit = SubmitField(_l('Submit')) diff --git a/app/main/routes.py b/app/main/routes.py index 1447153..26a5e67 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -5,8 +5,8 @@ from flask_login import current_user, login_required from flask_babel import _, get_locale from guess_language import guess_language from app import db -from app.main.forms import EditProfileForm, PostForm, SearchForm -from app.models import User, Post +from app.main.forms import EditProfileForm, PostForm, SearchForm, MessageForm +from app.models import User, Post, Message, Notification from app.translate import translate from app.main import bp @@ -155,3 +155,51 @@ def search(): if page > 1 else None return render_template('search.html', title=_('Search'), posts=posts, next_url=next_url, prev_url=prev_url) + + +@bp.route('/send_message/', methods=['GET', 'POST']) +@login_required +def send_message(recipient): + user = User.query.filter_by(username=recipient).first_or_404() + form = MessageForm() + if form.validate_on_submit(): + msg = Message(author=current_user, recipient=user, + body=form.message.data) + db.session.add(msg) + user.add_notification('unread_message_count', user.new_messages()) + db.session.commit() + flash(_('Your message has been sent.')) + return redirect(url_for('main.user', username=recipient)) + return render_template('send_message.html', title=_('Send Message'), + form=form, recipient=recipient) + + +@bp.route('/messages') +@login_required +def messages(): + current_user.last_message_read_time = datetime.utcnow() + current_user.add_notification('unread_message_count', 0) + db.session.commit() + page = request.args.get('page', 1, type=int) + messages = current_user.messages_received.order_by( + Message.timestamp.desc()).paginate( + page, current_app.config['POSTS_PER_PAGE'], False) + next_url = url_for('main.messages', page=messages.next_num) \ + if messages.has_next else None + prev_url = url_for('main.messages', page=messages.prev_num) \ + if messages.has_prev else None + return render_template('messages.html', messages=messages.items, + next_url=next_url, prev_url=prev_url) + + +@bp.route('/notifications') +@login_required +def notifications(): + since = request.args.get('since', 0.0, type=float) + notifications = current_user.notifications.filter( + Notification.timestamp > since).order_by(Notification.timestamp.asc()) + return jsonify([{ + 'name': n.name, + 'data': n.get_data(), + 'timestamp': n.timestamp + } for n in notifications]) diff --git a/app/models.py b/app/models.py index ecd6b20..6ea7de0 100644 --- a/app/models.py +++ b/app/models.py @@ -1,5 +1,6 @@ from datetime import datetime from hashlib import md5 +import json from time import time from flask import current_app from flask_login import UserMixin @@ -72,6 +73,15 @@ class User(UserMixin, db.Model): primaryjoin=(followers.c.follower_id == id), secondaryjoin=(followers.c.followed_id == id), backref=db.backref('followers', lazy='dynamic'), lazy='dynamic') + messages_sent = db.relationship('Message', + foreign_keys='Message.sender_id', + backref='author', lazy='dynamic') + messages_received = db.relationship('Message', + foreign_keys='Message.recipient_id', + backref='recipient', lazy='dynamic') + last_message_read_time = db.Column(db.DateTime) + notifications = db.relationship('Notification', backref='user', + lazy='dynamic') def __repr__(self): return ''.format(self.username) @@ -121,6 +131,17 @@ class User(UserMixin, db.Model): return return User.query.get(id) + def new_messages(self): + last_read_time = self.last_message_read_time or datetime(1900, 1, 1) + return Message.query.filter_by(recipient=self).filter( + Message.timestamp > last_read_time).count() + + def add_notification(self, name, data): + self.notifications.filter_by(name=name).delete() + n = Notification(name=name, payload_json=json.dumps(data), user=self) + db.session.add(n) + return n + @login.user_loader def load_user(id): @@ -137,3 +158,25 @@ class Post(SearchableMixin, db.Model): def __repr__(self): return ''.format(self.body) + + +class Message(db.Model): + id = db.Column(db.Integer, primary_key=True) + sender_id = db.Column(db.Integer, db.ForeignKey('user.id')) + recipient_id = db.Column(db.Integer, db.ForeignKey('user.id')) + body = db.Column(db.String(140)) + timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow) + + def __repr__(self): + return ''.format(self.body) + + +class Notification(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128), index=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + timestamp = db.Column(db.Float, index=True, default=time) + payload_json = db.Column(db.Text) + + def get_data(self): + return json.loads(str(self.payload_json)) diff --git a/app/templates/base.html b/app/templates/base.html index b5f970f..b32f0ce 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -32,6 +32,16 @@ {% if current_user.is_anonymous %}
  • {{ _('Login') }}
  • {% else %} +
  • + {{ _('Messages') }} + {% set new_messages = current_user.new_messages() %} + + {{ new_messages }} + + +
  • {{ _('Profile') }}
  • {{ _('Logout') }}
  • {% endif %} @@ -115,5 +125,25 @@ } ); }); + function set_message_count(n) { + $('#message_count').text(n); + $('#message_count').css('visibility', n ? 'visible' : 'hidden'); + } + {% if current_user.is_authenticated %} + $(function() { + var since = 0; + setInterval(function() { + $.ajax('{{ url_for('main.notifications') }}?since=' + since).done( + function(notifications) { + for (var i = 0; i < notifications.length; i++) { + if (notifications[i].name == 'unread_message_count') + set_message_count(notifications[i].data); + since = notifications[i].timestamp; + } + } + ); + }, 10000); + }); + {% endif %} {% endblock %} diff --git a/app/templates/messages.html b/app/templates/messages.html new file mode 100644 index 0000000..fdfd047 --- /dev/null +++ b/app/templates/messages.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block app_content %} +

    {{ _('Messages') }}

    + {% for post in messages %} + {% include '_post.html' %} + {% endfor %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/send_message.html b/app/templates/send_message.html new file mode 100644 index 0000000..2987d6a --- /dev/null +++ b/app/templates/send_message.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block app_content %} +

    {{ _('Send Message to %(recipient)s', recipient=recipient) }}

    +
    +
    + {{ wtf.quick_form(form) }} +
    +
    +{% endblock %} diff --git a/app/templates/user.html b/app/templates/user.html index 1478be8..2f5977b 100644 --- a/app/templates/user.html +++ b/app/templates/user.html @@ -18,6 +18,9 @@ {% else %}

    {{ _('Unfollow') }}

    {% endif %} + {% if user != current_user %} +

    {{ _('Send private message') }}

    + {% endif %}
    diff --git a/app/translations/es/LC_MESSAGES/messages.po b/app/translations/es/LC_MESSAGES/messages.po index df667c9..dac4264 100644 --- a/app/translations/es/LC_MESSAGES/messages.po +++ b/app/translations/es/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2017-11-25 18:23-0800\n" +"POT-Creation-Date: 2017-11-25 18:26-0800\n" "PO-Revision-Date: 2017-09-29 23:25-0700\n" "Last-Translator: FULL NAME \n" "Language: es\n" @@ -94,7 +94,7 @@ msgstr "Tu contraseña ha sido cambiada." msgid "About me" msgstr "Acerca de mí" -#: app/main/forms.py:13 app/main/forms.py:28 +#: app/main/forms.py:13 app/main/forms.py:28 app/main/forms.py:44 msgid "Submit" msgstr "Enviar" @@ -106,47 +106,59 @@ msgstr "Dí algo" msgid "Search" msgstr "Buscar" +#: app/main/forms.py:43 +msgid "Message" +msgstr "Mensaje" + #: app/main/routes.py:36 msgid "Your post is now live!" msgstr "¡Tu artículo ha sido publicado!" -#: app/main/routes.py:87 +#: app/main/routes.py:94 msgid "Your changes have been saved." msgstr "Tus cambios han sido salvados." -#: app/main/routes.py:92 app/templates/edit_profile.html:5 +#: app/main/routes.py:99 app/templates/edit_profile.html:5 msgid "Edit Profile" msgstr "Editar Perfil" -#: app/main/routes.py:101 app/main/routes.py:117 +#: app/main/routes.py:108 app/main/routes.py:124 #, python-format msgid "User %(username)s not found." msgstr "El usuario %(username)s no ha sido encontrado." -#: app/main/routes.py:104 +#: app/main/routes.py:111 msgid "You cannot follow yourself!" msgstr "¡No te puedes seguir a tí mismo!" -#: app/main/routes.py:108 +#: app/main/routes.py:115 #, python-format msgid "You are following %(username)s!" msgstr "¡Ahora estás siguiendo a %(username)s!" -#: app/main/routes.py:120 +#: app/main/routes.py:127 msgid "You cannot unfollow yourself!" msgstr "¡No te puedes dejar de seguir a tí mismo!" -#: app/main/routes.py:124 +#: app/main/routes.py:131 #, python-format msgid "You are not following %(username)s." msgstr "No estás siguiendo a %(username)s." -#: app/templates/_post.html:14 +#: app/main/routes.py:170 +msgid "Your message has been sent." +msgstr "Tu mensaje ha sido enviado." + +#: app/main/routes.py:172 +msgid "Send Message" +msgstr "Enviar Mensaje" + +#: app/templates/_post.html:16 #, python-format msgid "%(username)s said %(when)s" msgstr "%(username)s dijo %(when)s" -#: app/templates/_post.html:25 +#: app/templates/_post.html:27 msgid "Translate" msgstr "Traducir" @@ -166,15 +178,19 @@ msgstr "Explorar" msgid "Login" msgstr "Ingresar" -#: app/templates/base.html:35 +#: app/templates/base.html:36 app/templates/messages.html:4 +msgid "Messages" +msgstr "Mensajes" + +#: app/templates/base.html:45 msgid "Profile" msgstr "Perfil" -#: app/templates/base.html:36 +#: app/templates/base.html:46 msgid "Logout" msgstr "Salir" -#: app/templates/base.html:73 +#: app/templates/base.html:83 msgid "Error: Could not contact server." msgstr "Error: el servidor no pudo ser contactado." @@ -183,40 +199,53 @@ msgstr "Error: el servidor no pudo ser contactado." msgid "Hi, %(username)s!" msgstr "¡Hola, %(username)s!" -#: app/templates/index.html:17 app/templates/user.html:31 +#: app/templates/index.html:17 app/templates/user.html:34 msgid "Newer posts" msgstr "Artículos siguientes" -#: app/templates/index.html:22 app/templates/user.html:36 +#: app/templates/index.html:22 app/templates/user.html:39 msgid "Older posts" msgstr "Artículos previos" +#: app/templates/messages.html:12 +msgid "Newer messages" +msgstr "Mensajes siguientes" + +#: app/templates/messages.html:17 +msgid "Older messages" +msgstr "Mensajes previos" + #: app/templates/search.html:4 msgid "Search Results" -msgstr "Resultados de Búsqueda" +msgstr "" #: app/templates/search.html:12 msgid "Previous results" -msgstr "Resultados previos" +msgstr "" #: app/templates/search.html:17 msgid "Next results" -msgstr "Resultados próximos" +msgstr "" + +#: app/templates/send_message.html:5 +#, python-format +msgid "Send Message to %(recipient)s" +msgstr "Enviar Mensaje a %(recipient)s" #: app/templates/user.html:8 msgid "User" msgstr "Usuario" -#: app/templates/user.html:11 +#: app/templates/user.html:11 app/templates/user_popup.html:9 msgid "Last seen on" msgstr "Última visita" -#: app/templates/user.html:13 +#: app/templates/user.html:13 app/templates/user_popup.html:11 #, python-format msgid "%(count)d followers" msgstr "%(count)d seguidores" -#: app/templates/user.html:13 +#: app/templates/user.html:13 app/templates/user_popup.html:11 #, python-format msgid "%(count)d following" msgstr "siguiendo a %(count)d" @@ -225,14 +254,18 @@ msgstr "siguiendo a %(count)d" msgid "Edit your profile" msgstr "Editar tu perfil" -#: app/templates/user.html:17 +#: app/templates/user.html:17 app/templates/user_popup.html:14 msgid "Follow" msgstr "Seguir" -#: app/templates/user.html:19 +#: app/templates/user.html:19 app/templates/user_popup.html:16 msgid "Unfollow" msgstr "Dejar de seguir" +#: app/templates/user.html:22 +msgid "Send private message" +msgstr "Enviar mensaje privado" + #: app/templates/auth/login.html:12 msgid "New User?" msgstr "¿Usuario Nuevo?" diff --git a/microblog.py b/microblog.py index 20d62e6..499da2a 100644 --- a/microblog.py +++ b/microblog.py @@ -1,5 +1,5 @@ from app import create_app, db, cli -from app.models import User, Post +from app.models import User, Post, Message, Notification app = create_app() cli.register(app) @@ -7,4 +7,5 @@ cli.register(app) @app.shell_context_processor def make_shell_context(): - return {'db': db, 'User': User, 'Post': Post} + return {'db': db, 'User': User, 'Post': Post, 'Message': Message, + 'Notification': Notification} diff --git a/migrations/versions/d049de007ccf_private_messages.py b/migrations/versions/d049de007ccf_private_messages.py new file mode 100644 index 0000000..c1f3be9 --- /dev/null +++ b/migrations/versions/d049de007ccf_private_messages.py @@ -0,0 +1,41 @@ +"""private messages + +Revision ID: d049de007ccf +Revises: 834b1a697901 +Create Date: 2017-11-12 23:30:28.571784 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd049de007ccf' +down_revision = '2b017edaa91f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('message', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('sender_id', sa.Integer(), nullable=True), + sa.Column('recipient_id', sa.Integer(), nullable=True), + sa.Column('body', sa.String(length=140), nullable=True), + sa.Column('timestamp', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['recipient_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['sender_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_message_timestamp'), 'message', ['timestamp'], unique=False) + op.add_column('user', sa.Column('last_message_read_time', sa.DateTime(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'last_message_read_time') + op.drop_index(op.f('ix_message_timestamp'), table_name='message') + op.drop_table('message') + # ### end Alembic commands ### diff --git a/migrations/versions/f7ac3d27bb1d_notifications.py b/migrations/versions/f7ac3d27bb1d_notifications.py new file mode 100644 index 0000000..9cc7b04 --- /dev/null +++ b/migrations/versions/f7ac3d27bb1d_notifications.py @@ -0,0 +1,40 @@ +"""notifications + +Revision ID: f7ac3d27bb1d +Revises: d049de007ccf +Create Date: 2017-11-22 19:48:39.945858 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f7ac3d27bb1d' +down_revision = 'd049de007ccf' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('notification', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('timestamp', sa.Float(), nullable=True), + sa.Column('payload_json', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_notification_name'), 'notification', ['name'], unique=False) + op.create_index(op.f('ix_notification_timestamp'), 'notification', ['timestamp'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_notification_timestamp'), table_name='notification') + op.drop_index(op.f('ix_notification_name'), table_name='notification') + op.drop_table('notification') + # ### end Alembic commands ### From c3127e29bc567154a14a30bb7df8d9c40436aa03 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Sun, 19 Nov 2017 23:57:09 -0800 Subject: [PATCH 8/9] Chapter 22: Background Jobs (v0.22) --- Procfile | 1 + app/__init__.py | 4 ++ app/email.py | 13 ++++-- app/main/routes.py | 11 +++++ app/models.py | 37 +++++++++++++++ app/tasks.py | 51 +++++++++++++++++++++ app/templates/base.html | 26 ++++++++++- app/templates/email/export_posts.html | 4 ++ app/templates/email/export_posts.txt | 7 +++ app/templates/user.html | 3 ++ app/translations/es/LC_MESSAGES/messages.po | 28 +++++++---- config.py | 1 + deployment/supervisor/microblog-tasks.conf | 9 ++++ microblog.py | 4 +- migrations/versions/c81bac34faab_tasks.py | 38 +++++++++++++++ requirements.txt | 2 + 16 files changed, 224 insertions(+), 15 deletions(-) create mode 100644 app/tasks.py create mode 100644 app/templates/email/export_posts.html create mode 100644 app/templates/email/export_posts.txt create mode 100644 deployment/supervisor/microblog-tasks.conf create mode 100644 migrations/versions/c81bac34faab_tasks.py diff --git a/Procfile b/Procfile index 216c639..62bc894 100644 --- a/Procfile +++ b/Procfile @@ -1 +1,2 @@ web: flask db upgrade; flask translate compile; gunicorn microblog:app +worker: rq worker microblog-tasks diff --git a/app/__init__.py b/app/__init__.py index 1abc540..e805190 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -10,6 +10,8 @@ from flask_bootstrap import Bootstrap from flask_moment import Moment from flask_babel import Babel, lazy_gettext as _l from elasticsearch import Elasticsearch +from redis import Redis +import rq from config import Config db = SQLAlchemy() @@ -36,6 +38,8 @@ def create_app(config_class=Config): babel.init_app(app) app.elasticsearch = Elasticsearch([app.config['ELASTICSEARCH_URL']]) \ if app.config['ELASTICSEARCH_URL'] else None + app.redis = Redis.from_url(app.config['REDIS_URL']) + app.task_queue = rq.Queue('microblog-tasks', connection=app.redis) from app.errors import bp as errors_bp app.register_blueprint(errors_bp) diff --git a/app/email.py b/app/email.py index ee23da8..9779fa2 100644 --- a/app/email.py +++ b/app/email.py @@ -9,9 +9,16 @@ def send_async_email(app, msg): mail.send(msg) -def send_email(subject, sender, recipients, text_body, html_body): +def send_email(subject, sender, recipients, text_body, html_body, + attachments=None, sync=False): msg = Message(subject, sender=sender, recipients=recipients) msg.body = text_body msg.html = html_body - Thread(target=send_async_email, - args=(current_app._get_current_object(), msg)).start() + if attachments: + for attachment in attachments: + msg.attach(*attachment) + if sync: + mail.send(msg) + else: + Thread(target=send_async_email, + args=(current_app._get_current_object(), msg)).start() diff --git a/app/main/routes.py b/app/main/routes.py index 26a5e67..41d0179 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -192,6 +192,17 @@ def messages(): next_url=next_url, prev_url=prev_url) +@bp.route('/export_posts') +@login_required +def export_posts(): + if current_user.get_task_in_progress('export_posts'): + flash(_('An export task is currently in progress')) + else: + current_user.launch_task('export_posts', _('Exporting posts...')) + db.session.commit() + return redirect(url_for('main.user', username=current_user.username)) + + @bp.route('/notifications') @login_required def notifications(): diff --git a/app/models.py b/app/models.py index 6ea7de0..2e585f1 100644 --- a/app/models.py +++ b/app/models.py @@ -6,6 +6,8 @@ from flask import current_app from flask_login import UserMixin from werkzeug.security import generate_password_hash, check_password_hash import jwt +import redis +import rq from app import db, login from app.search import add_to_index, remove_from_index, query_index @@ -82,6 +84,7 @@ class User(UserMixin, db.Model): last_message_read_time = db.Column(db.DateTime) notifications = db.relationship('Notification', backref='user', lazy='dynamic') + tasks = db.relationship('Task', backref='user', lazy='dynamic') def __repr__(self): return ''.format(self.username) @@ -142,6 +145,21 @@ class User(UserMixin, db.Model): db.session.add(n) return n + def launch_task(self, name, description, *args, **kwargs): + rq_job = current_app.task_queue.enqueue('app.tasks.' + name, self.id, + *args, **kwargs) + task = Task(id=rq_job.get_id(), name=name, description=description, + user=self) + db.session.add(task) + return task + + def get_tasks_in_progress(self): + return Task.query.filter_by(user=self, complete=False).all() + + def get_task_in_progress(self, name): + return Task.query.filter_by(name=name, user=self, + complete=False).first() + @login.user_loader def load_user(id): @@ -180,3 +198,22 @@ class Notification(db.Model): def get_data(self): return json.loads(str(self.payload_json)) + + +class Task(db.Model): + id = db.Column(db.String(36), primary_key=True) + name = db.Column(db.String(128), index=True) + description = db.Column(db.String(128)) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + complete = db.Column(db.Boolean, default=False) + + def get_rq_job(self): + try: + rq_job = rq.job.Job.fetch(self.id, connection=current_app.redis) + except (redis.exceptions.RedisError, rq.exceptions.NoSuchJobError): + return None + return rq_job + + def get_progress(self): + job = self.get_rq_job() + return job.meta.get('progress', 0) if job is not None else 100 diff --git a/app/tasks.py b/app/tasks.py new file mode 100644 index 0000000..04d677f --- /dev/null +++ b/app/tasks.py @@ -0,0 +1,51 @@ +import json +import sys +import time +from flask import render_template +from rq import get_current_job +from app import create_app, db +from app.models import User, Post, Task +from app.email import send_email + +app = create_app() +app.app_context().push() + + +def _set_task_progress(progress): + job = get_current_job() + if job: + job.meta['progress'] = progress + job.save_meta() + task = Task.query.get(job.get_id()) + task.user.add_notification('task_progress', {'task_id': job.get_id(), + 'progress': progress}) + if progress >= 100: + task.complete = True + db.session.commit() + + +def export_posts(user_id): + try: + user = User.query.get(user_id) + _set_task_progress(0) + data = [] + i = 0 + total_posts = user.posts.count() + for post in user.posts.order_by(Post.timestamp.asc()): + data.append({'body': post.body, + 'timestamp': post.timestamp.isoformat() + 'Z'}) + time.sleep(5) + i += 1 + _set_task_progress(100 * i // total_posts) + + send_email('[Microblog] Your blog posts', + sender=app.config['ADMINS'][0], recipients=[user.email], + text_body=render_template('email/export_posts.txt', user=user), + html_body=render_template('email/export_posts.html', + user=user), + attachments=[('posts.json', 'application/json', + json.dumps({'posts': data}, indent=4))], + sync=True) + except: + _set_task_progress(100) + app.logger.error('Unhandled exception', exc_info=sys.exc_info()) diff --git a/app/templates/base.html b/app/templates/base.html index b32f0ce..eacee92 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -53,6 +53,18 @@ {% block content %}
    + {% if current_user.is_authenticated %} + {% with tasks = current_user.get_tasks_in_progress() %} + {% if tasks %} + {% for task in tasks %} + + {% endfor %} + {% endif %} + {% endwith %} + {% endif %} {% with messages = get_flashed_messages() %} {% if messages %} {% for message in messages %} @@ -129,6 +141,9 @@ $('#message_count').text(n); $('#message_count').css('visibility', n ? 'visible' : 'hidden'); } + function set_task_progress(task_id, progress) { + $('#' + task_id + '-progress').text(progress); + } {% if current_user.is_authenticated %} $(function() { var since = 0; @@ -136,8 +151,15 @@ $.ajax('{{ url_for('main.notifications') }}?since=' + since).done( function(notifications) { for (var i = 0; i < notifications.length; i++) { - if (notifications[i].name == 'unread_message_count') - set_message_count(notifications[i].data); + switch (notifications[i].name) { + case 'unread_message_count': + set_message_count(notifications[i].data); + break; + case 'task_progress': + set_task_progress(notifications[i].data.task_id, + notifications[i].data.progress); + break; + } since = notifications[i].timestamp; } } diff --git a/app/templates/email/export_posts.html b/app/templates/email/export_posts.html new file mode 100644 index 0000000..f98383a --- /dev/null +++ b/app/templates/email/export_posts.html @@ -0,0 +1,4 @@ +

    Dear {{ user.username }},

    +

    Please find attached the archive of your posts that you requested.

    +

    Sincerely,

    +

    The Microblog Team

    diff --git a/app/templates/email/export_posts.txt b/app/templates/email/export_posts.txt new file mode 100644 index 0000000..81c9f7a --- /dev/null +++ b/app/templates/email/export_posts.txt @@ -0,0 +1,7 @@ +Dear {{ user.username }}, + +Please find attached the archive of your posts that you requested. + +Sincerely, + +The Microblog Team diff --git a/app/templates/user.html b/app/templates/user.html index 2f5977b..69adb65 100644 --- a/app/templates/user.html +++ b/app/templates/user.html @@ -13,6 +13,9 @@

    {{ _('%(count)d followers', count=user.followers.count()) }}, {{ _('%(count)d following', count=user.followed.count()) }}

    {% if user == current_user %}

    {{ _('Edit your profile') }}

    + {% if not current_user.get_task_in_progress('export_posts') %} +

    {{ _('Export your posts') }}

    + {% endif %} {% elif not current_user.is_following(user) %}

    {{ _('Follow') }}

    {% else %} diff --git a/app/translations/es/LC_MESSAGES/messages.po b/app/translations/es/LC_MESSAGES/messages.po index dac4264..ad0e8ed 100644 --- a/app/translations/es/LC_MESSAGES/messages.po +++ b/app/translations/es/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2017-11-25 18:26-0800\n" +"POT-Creation-Date: 2017-11-25 18:27-0800\n" "PO-Revision-Date: 2017-09-29 23:25-0700\n" "Last-Translator: FULL NAME \n" "Language: es\n" @@ -18,7 +18,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.5.1\n" -#: app/__init__.py:18 +#: app/__init__.py:20 msgid "Please log in to access this page." msgstr "Por favor ingrese para acceder a esta página." @@ -153,6 +153,14 @@ msgstr "Tu mensaje ha sido enviado." msgid "Send Message" msgstr "Enviar Mensaje" +#: app/main/routes.py:197 +msgid "An export task is currently in progress" +msgstr "Una tarea de exportación esta en progreso" + +#: app/main/routes.py:199 +msgid "Exporting posts..." +msgstr "Exportando artículos..." + #: app/templates/_post.html:16 #, python-format msgid "%(username)s said %(when)s" @@ -190,7 +198,7 @@ msgstr "Perfil" msgid "Logout" msgstr "Salir" -#: app/templates/base.html:83 +#: app/templates/base.html:95 msgid "Error: Could not contact server." msgstr "Error: el servidor no pudo ser contactado." @@ -199,11 +207,11 @@ msgstr "Error: el servidor no pudo ser contactado." msgid "Hi, %(username)s!" msgstr "¡Hola, %(username)s!" -#: app/templates/index.html:17 app/templates/user.html:34 +#: app/templates/index.html:17 app/templates/user.html:37 msgid "Newer posts" msgstr "Artículos siguientes" -#: app/templates/index.html:22 app/templates/user.html:39 +#: app/templates/index.html:22 app/templates/user.html:42 msgid "Older posts" msgstr "Artículos previos" @@ -254,15 +262,19 @@ msgstr "siguiendo a %(count)d" msgid "Edit your profile" msgstr "Editar tu perfil" -#: app/templates/user.html:17 app/templates/user_popup.html:14 +#: app/templates/user.html:17 +msgid "Export your posts" +msgstr "Exportar tus artículos" + +#: app/templates/user.html:20 app/templates/user_popup.html:14 msgid "Follow" msgstr "Seguir" -#: app/templates/user.html:19 app/templates/user_popup.html:16 +#: app/templates/user.html:22 app/templates/user_popup.html:16 msgid "Unfollow" msgstr "Dejar de seguir" -#: app/templates/user.html:22 +#: app/templates/user.html:25 msgid "Send private message" msgstr "Enviar mensaje privado" diff --git a/config.py b/config.py index 15de317..6bd46e4 100644 --- a/config.py +++ b/config.py @@ -20,4 +20,5 @@ class Config(object): LANGUAGES = ['en', 'es'] MS_TRANSLATOR_KEY = os.environ.get('MS_TRANSLATOR_KEY') ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL') + REDIS_URL = os.environ.get('REDIS_URL') or 'redis://' POSTS_PER_PAGE = 25 diff --git a/deployment/supervisor/microblog-tasks.conf b/deployment/supervisor/microblog-tasks.conf new file mode 100644 index 0000000..d47b6da --- /dev/null +++ b/deployment/supervisor/microblog-tasks.conf @@ -0,0 +1,9 @@ +[program:microblog-tasks] +command=/home/ubuntu/microblog/venv/bin/rq worker microblog-tasks +numprocs=1 +directory=/home/ubuntu/microblog +user=ubuntu +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true diff --git a/microblog.py b/microblog.py index 499da2a..67d0890 100644 --- a/microblog.py +++ b/microblog.py @@ -1,5 +1,5 @@ from app import create_app, db, cli -from app.models import User, Post, Message, Notification +from app.models import User, Post, Message, Notification, Task app = create_app() cli.register(app) @@ -8,4 +8,4 @@ cli.register(app) @app.shell_context_processor def make_shell_context(): return {'db': db, 'User': User, 'Post': Post, 'Message': Message, - 'Notification': Notification} + 'Notification': Notification, 'Task': Task} diff --git a/migrations/versions/c81bac34faab_tasks.py b/migrations/versions/c81bac34faab_tasks.py new file mode 100644 index 0000000..164b926 --- /dev/null +++ b/migrations/versions/c81bac34faab_tasks.py @@ -0,0 +1,38 @@ +"""tasks + +Revision ID: c81bac34faab +Revises: f7ac3d27bb1d +Create Date: 2017-11-23 10:56:49.599779 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c81bac34faab' +down_revision = 'f7ac3d27bb1d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('task', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('description', sa.String(length=128), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('complete', sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_task_name'), 'task', ['name'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_task_name'), table_name='task') + op.drop_table('task') + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index 9bbc5b2..05815ab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,7 +26,9 @@ python-dateutil==2.6.1 python-dotenv==0.7.1 python-editor==1.0.3 pytz==2017.2 +redis==2.10.6 requests==2.18.4 +rq==0.9.2 six==1.11.0 SQLAlchemy==1.1.14 urllib3==1.22 From 5e9a8d7da8ffed36df9cc8735813da2dbfa10341 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Wed, 1 Nov 2017 23:43:40 -0700 Subject: [PATCH 9/9] Chapter 23: Application Programming Interfaces (APIs) (v0.23) --- app/__init__.py | 3 + app/api/__init__.py | 5 ++ app/api/auth.py | 33 ++++++++ app/api/errors.py | 15 ++++ app/api/tokens.py | 20 +++++ app/api/users.py | 78 ++++++++++++++++++ app/errors/handlers.py | 12 ++- app/models.py | 80 ++++++++++++++++++- .../versions/834b1a697901_user_tokens.py | 32 ++++++++ requirements.txt | 1 + 10 files changed, 275 insertions(+), 4 deletions(-) create mode 100644 app/api/__init__.py create mode 100644 app/api/auth.py create mode 100644 app/api/errors.py create mode 100644 app/api/tokens.py create mode 100644 app/api/users.py create mode 100644 migrations/versions/834b1a697901_user_tokens.py diff --git a/app/__init__.py b/app/__init__.py index e805190..902ce6f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -50,6 +50,9 @@ def create_app(config_class=Config): from app.main import bp as main_bp app.register_blueprint(main_bp) + from app.api import bp as api_bp + app.register_blueprint(api_bp, url_prefix='/api') + if not app.debug and not app.testing: if app.config['MAIL_SERVER']: auth = None diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..61b2e60 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('api', __name__) + +from app.api import users, errors, tokens diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..cc01ce7 --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,33 @@ +from flask import g +from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth +from flask_login import current_user +from app.models import User +from app.api.errors import error_response + +basic_auth = HTTPBasicAuth() +token_auth = HTTPTokenAuth() + + +@basic_auth.verify_password +def verify_password(username, password): + user = User.query.filter_by(username=username).first() + if user is None: + return False + g.current_user = user + return user.check_password(password) + + +@basic_auth.error_handler +def basic_auth_error(): + return error_response(401) + + +@token_auth.verify_token +def verify_token(token): + g.current_user = User.check_token(token) if token else None + return g.current_user is not None + + +@token_auth.error_handler +def token_auth_error(): + return error_response(401) diff --git a/app/api/errors.py b/app/api/errors.py new file mode 100644 index 0000000..4167eb4 --- /dev/null +++ b/app/api/errors.py @@ -0,0 +1,15 @@ +from flask import jsonify +from werkzeug.http import HTTP_STATUS_CODES + + +def error_response(status_code, message=None): + payload = {'error': HTTP_STATUS_CODES.get(status_code, 'Unknown error')} + if message: + payload['message'] = message + response = jsonify(payload) + response.status_code = status_code + return response + + +def bad_request(message): + return error_response(400, message) diff --git a/app/api/tokens.py b/app/api/tokens.py new file mode 100644 index 0000000..6dccf20 --- /dev/null +++ b/app/api/tokens.py @@ -0,0 +1,20 @@ +from flask import jsonify, g +from app import db +from app.api import bp +from app.api.auth import basic_auth, token_auth + + +@bp.route('/tokens', methods=['POST']) +@basic_auth.login_required +def get_token(): + token = g.current_user.get_token() + db.session.commit() + return jsonify({'token': token}) + + +@bp.route('/tokens', methods=['DELETE']) +@token_auth.login_required +def revoke_token(): + g.current_user.revoke_token() + db.session.commit() + return '', 204 diff --git a/app/api/users.py b/app/api/users.py new file mode 100644 index 0000000..9128ee5 --- /dev/null +++ b/app/api/users.py @@ -0,0 +1,78 @@ +from flask import jsonify, request, url_for +from app import db +from app.models import User +from app.api import bp +from app.api.auth import token_auth +from app.api.errors import bad_request + + +@bp.route('/users/', methods=['GET']) +@token_auth.login_required +def get_user(id): + return jsonify(User.query.get_or_404(id).to_dict()) + + +@bp.route('/users', methods=['GET']) +@token_auth.login_required +def get_users(): + page = request.args.get('page', 1, type=int) + per_page = min(request.args.get('per_page', 10, type=int), 100) + data = User.to_collection_dict(User.query, page, per_page, 'api.get_users') + return jsonify(data) + + +@bp.route('/users//followers', methods=['GET']) +@token_auth.login_required +def get_followers(id): + user = User.query.get_or_404(id) + page = request.args.get('page', 1, type=int) + per_page = min(request.args.get('per_page', 10, type=int), 100) + data = User.to_collection_dict(user.followers, page, per_page, + 'api.get_followers', id=id) + return jsonify(data) + + +@bp.route('/users//followed', methods=['GET']) +@token_auth.login_required +def get_followed(id): + user = User.query.get_or_404(id) + page = request.args.get('page', 1, type=int) + per_page = min(request.args.get('per_page', 10, type=int), 100) + data = User.to_collection_dict(user.followed, page, per_page, + 'api.get_followed', id=id) + return jsonify(data) + + +@bp.route('/users', methods=['POST']) +def create_user(): + data = request.get_json() or {} + if 'username' not in data or 'email' not in data or 'password' not in data: + return bad_request('must include username, email and password fields') + if User.query.filter_by(username=data['username']).first(): + return bad_request('please use a different username') + if User.query.filter_by(email=data['email']).first(): + return bad_request('please use a different email address') + user = User() + user.from_dict(data, new_user=True) + db.session.add(user) + db.session.commit() + response = jsonify(user.to_dict()) + response.status_code = 201 + response.headers['Location'] = url_for('api.get_user', id=user.id) + return response + + +@bp.route('/users/', methods=['PUT']) +@token_auth.login_required +def update_user(id): + user = User.query.get_or_404(id) + data = request.get_json() or {} + if 'username' in data and data['username'] != user.username and \ + User.query.filter_by(username=data['username']).first(): + return bad_request('please use a different username') + if 'email' in data and data['email'] != user.email and \ + User.query.filter_by(email=data['email']).first(): + return bad_request('please use a different email address') + user.from_dict(data, new_user=False) + db.session.commit() + return jsonify(user.to_dict()) diff --git a/app/errors/handlers.py b/app/errors/handlers.py index 4a40ad9..62d42ad 100644 --- a/app/errors/handlers.py +++ b/app/errors/handlers.py @@ -1,14 +1,24 @@ -from flask import render_template +from flask import render_template, request from app import db from app.errors import bp +from app.api.errors import error_response as api_error_response + + +def wants_json_response(): + return request.accept_mimetypes['application/json'] >= \ + request.accept_mimetypes['text/html'] @bp.app_errorhandler(404) def not_found_error(error): + if wants_json_response(): + return api_error_response(404) return render_template('errors/404.html'), 404 @bp.app_errorhandler(500) def internal_error(error): db.session.rollback() + if wants_json_response(): + return api_error_response(500) return render_template('errors/500.html'), 500 diff --git a/app/models.py b/app/models.py index 2e585f1..bb82138 100644 --- a/app/models.py +++ b/app/models.py @@ -1,8 +1,10 @@ -from datetime import datetime +import base64 +from datetime import datetime, timedelta from hashlib import md5 import json +import os from time import time -from flask import current_app +from flask import current_app, url_for from flask_login import UserMixin from werkzeug.security import generate_password_hash, check_password_hash import jwt @@ -55,6 +57,30 @@ db.event.listen(db.session, 'before_commit', SearchableMixin.before_commit) db.event.listen(db.session, 'after_commit', SearchableMixin.after_commit) +class PaginatedAPIMixin(object): + @staticmethod + def to_collection_dict(query, page, per_page, endpoint, **kwargs): + resources = query.paginate(page, per_page, False) + data = { + 'items': [item.to_dict() for item in resources.items], + '_meta': { + 'page': page, + 'per_page': per_page, + 'total_pages': resources.pages, + 'total_items': resources.total + }, + '_links': { + 'self': url_for(endpoint, page=page, per_page=per_page, + **kwargs), + 'next': url_for(endpoint, page=page + 1, per_page=per_page, + **kwargs) if resources.has_next else None, + 'prev': url_for(endpoint, page=page - 1, per_page=per_page, + **kwargs) if resources.has_prev else None + } + } + return data + + followers = db.Table( 'followers', db.Column('follower_id', db.Integer, db.ForeignKey('user.id')), @@ -62,7 +88,7 @@ followers = db.Table( ) -class User(UserMixin, db.Model): +class User(UserMixin, PaginatedAPIMixin, db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(64), index=True, unique=True) email = db.Column(db.String(120), index=True, unique=True) @@ -70,6 +96,8 @@ class User(UserMixin, db.Model): posts = db.relationship('Post', backref='author', lazy='dynamic') about_me = db.Column(db.String(140)) last_seen = db.Column(db.DateTime, default=datetime.utcnow) + token = db.Column(db.String(32), index=True, unique=True) + token_expiration = db.Column(db.DateTime) followed = db.relationship( 'User', secondary=followers, primaryjoin=(followers.c.follower_id == id), @@ -160,6 +188,52 @@ class User(UserMixin, db.Model): return Task.query.filter_by(name=name, user=self, complete=False).first() + def to_dict(self, include_email=False): + data = { + 'id': self.id, + 'username': self.username, + 'last_seen': self.last_seen.isoformat() + 'Z', + 'about_me': self.about_me, + 'post_count': self.posts.count(), + 'follower_count': self.followers.count(), + 'followed_count': self.followed.count(), + '_links': { + 'self': url_for('api.get_user', id=self.id), + 'followers': url_for('api.get_followers', id=self.id), + 'followed': url_for('api.get_followed', id=self.id), + 'avatar': self.avatar(128) + } + } + if include_email: + data['email'] = self.email + return data + + def from_dict(self, data, new_user=False): + for field in ['username', 'email', 'about_me']: + if field in data: + setattr(self, field, data[field]) + if new_user and 'password' in data: + self.set_password(data['password']) + + def get_token(self, expires_in=3600): + now = datetime.utcnow() + if self.token and self.token_expiration > now + timedelta(seconds=60): + return self.token + self.token = base64.b64encode(os.urandom(24)).decode('utf-8') + self.token_expiration = now + timedelta(seconds=expires_in) + db.session.add(self) + return self.token + + def revoke_token(self): + self.token_expiration = datetime.utcnow() - timedelta(seconds=1) + + @staticmethod + def check_token(token): + user = User.query.filter_by(token=token).first() + if user is None or user.token_expiration < datetime.utcnow(): + return None + return user + @login.user_loader def load_user(id): diff --git a/migrations/versions/834b1a697901_user_tokens.py b/migrations/versions/834b1a697901_user_tokens.py new file mode 100644 index 0000000..4508a0b --- /dev/null +++ b/migrations/versions/834b1a697901_user_tokens.py @@ -0,0 +1,32 @@ +"""user tokens + +Revision ID: 834b1a697901 +Revises: c81bac34faab +Create Date: 2017-11-05 18:41:07.996137 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '834b1a697901' +down_revision = 'c81bac34faab' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('token', sa.String(length=32), nullable=True)) + op.add_column('user', sa.Column('token_expiration', sa.DateTime(), nullable=True)) + op.create_index(op.f('ix_user_token'), 'user', ['token'], unique=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_user_token'), table_name='user') + op.drop_column('user', 'token_expiration') + op.drop_column('user', 'token') + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index 05815ab..9de5c17 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ elasticsearch==6.1.1 Flask==1.0.2 Flask-Babel==0.11.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.1.1